Merge branch 'master' into feats/api-status

This commit is contained in:
Rinjae
2026-07-30 13:44:50 +09:00
104 changed files with 9013 additions and 3258 deletions
+4 -3
View File
@@ -80,9 +80,10 @@ dependencies {
// exclude group: 'commons-collections', module: 'commons-collections' // exclude group: 'commons-collections', module: 'commons-collections'
} }
implementation 'org.mapstruct:mapstruct:1.5.5.Final' implementation 'org.mapstruct:mapstruct:1.5.5.Final'
implementation 'com.fasterxml.jackson.core:jackson-core:2.15.3' // WS-2026-0003 (jackson-core async parser DoS, CVSS 7.5) — 2.18.6 에서 수정. JDK8 호환.
implementation 'com.fasterxml.jackson.core:jackson-annotations:2.15.3' implementation 'com.fasterxml.jackson.core:jackson-core:2.18.6'
implementation 'com.fasterxml.jackson.core:jackson-databind:2.15.3' implementation 'com.fasterxml.jackson.core:jackson-annotations:2.18.6'
implementation 'com.fasterxml.jackson.core:jackson-databind:2.18.6'
implementation group: 'org.apache.velocity', name: 'velocity-engine-core', version: '2.3' implementation group: 'org.apache.velocity', name: 'velocity-engine-core', version: '2.3'
@@ -64,6 +64,12 @@ public class ApiController {
Map<String, Object> searchResult = apiSearchFacade.searchApis(new ApiGroupSearch()); Map<String, Object> searchResult = apiSearchFacade.searchApis(new ApiGroupSearch());
// 상세 타이틀에 노출할 현재 API의 그룹명 세팅(selectDetail은 apiGroupName을 채우지 않음)
ApiServiceDTO apiGroup = apiServiceService.findApiServiceByApiId(id);
if (apiGroup != null) {
api.setApiGroupName(apiGroup.getGroupName());
}
model.addAttribute("apiSpecInfo", api); model.addAttribute("apiSpecInfo", api);
model.addAttribute("totalApiCount", searchResult.get("totalApiCount")); model.addAttribute("totalApiCount", searchResult.get("totalApiCount"));
model.addAttribute("services", searchResult.get("services")); model.addAttribute("services", searchResult.get("services"));
@@ -1,5 +1,6 @@
package com.eactive.apim.portal.apps.apis.filter; package com.eactive.apim.portal.apps.apis.filter;
import com.eactive.apim.portal.common.util.StringMaskingUtil;
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty; import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.DataOutputStream; import java.io.DataOutputStream;
@@ -37,13 +38,18 @@ public class APISender {
public String requestPost(String uri, String requestBody) throws IOException { public String requestPost(String uri, String requestBody) throws IOException {
if (logger.isDebugEnabled()) {
logger.debug("APISender POST(json) 요청 - uri={}, bodyLen={}, body={}",
uri, requestBody == null ? 0 : requestBody.length(), StringMaskingUtil.maskFormBody(requestBody));
}
HttpURLConnection connection = getHttpURLConnection(uri, requestBody); HttpURLConnection connection = getHttpURLConnection(uri, requestBody);
String response = getResponse(connection); String response = getResponse(connection);
connection.disconnect(); connection.disconnect();
if (logger.isDebugEnabled()) { if (logger.isDebugEnabled()) {
logger.debug(response); logger.debug("APISender POST(json) 응답 - uri={}, response={}", uri, response);
} }
return response; return response;
} }
@@ -85,11 +91,15 @@ public class APISender {
} }
connection.setDoOutput(true); connection.setDoOutput(true);
if (logger.isDebugEnabled()) {
logger.debug("APISender GET 요청 - uri={}", appendUriAndParams(uri, params));
}
String response = getResponse(connection); String response = getResponse(connection);
connection.disconnect(); connection.disconnect();
if (logger.isDebugEnabled()) { if (logger.isDebugEnabled()) {
logger.debug(response); logger.debug("APISender GET 응답 - uri={}, response={}", uri, response);
} }
return response; return response;
} }
@@ -110,6 +120,12 @@ public class APISender {
} }
connection.setDoOutput(true); connection.setDoOutput(true);
if (logger.isDebugEnabled()) {
// client_secret 등 민감 파라미터는 마스킹. body 비어있으면 상위에서 본문 전송 유실.
logger.debug("APISender POST 요청 - uri={}, bodyLen={}, body={}",
uri, requestBody == null ? 0 : requestBody.length(), StringMaskingUtil.maskFormBody(requestBody));
}
try (DataOutputStream outputStream = new DataOutputStream(connection.getOutputStream())) { try (DataOutputStream outputStream = new DataOutputStream(connection.getOutputStream())) {
byte[] requestBodyBytes = requestBody.getBytes(StandardCharsets.UTF_8); byte[] requestBodyBytes = requestBody.getBytes(StandardCharsets.UTF_8);
outputStream.write(requestBodyBytes); outputStream.write(requestBodyBytes);
@@ -120,7 +136,7 @@ public class APISender {
connection.disconnect(); connection.disconnect();
if (logger.isDebugEnabled()) { if (logger.isDebugEnabled()) {
logger.debug(response); logger.debug("APISender POST 응답 - uri={}, response={}", uri, response);
} }
return response; return response;
} }
@@ -4,12 +4,13 @@ package com.eactive.apim.portal.apps.apis.filter;
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto; import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
import com.eactive.apim.portal.apps.apis.service.ApiService; import com.eactive.apim.portal.apps.apis.service.ApiService;
import com.eactive.apim.portal.common.util.ApplicationContextUtil; import com.eactive.apim.portal.common.util.ApplicationContextUtil;
import com.eactive.apim.portal.common.util.StringMaskingUtil;
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty; import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
import com.eactive.apim.portal.djb.testbed.enums.DjbGatewayMode;
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.IOException; import java.io.IOException;
import java.net.URI; import java.net.URI;
import java.net.URISyntaxException; import java.net.URISyntaxException;
import java.net.URLEncoder;
import java.util.Enumeration; import java.util.Enumeration;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
@@ -72,6 +73,8 @@ public class ApiTesterFilter implements Filter {
String auditId = ApiTesterAuditLogger.newAuditId(); String auditId = ApiTesterAuditLogger.newAuditId();
long auditStart = System.currentTimeMillis(); long auditStart = System.currentTimeMillis();
String auditType = "-"; String auditType = "-";
// 실제 프록시 호출 대상 URL (mock 은 mockUrl, 토큰 GW 는 base-url+token-path 로 original-url 과 다를 수 있음) — 오류 로그용
String proxyTarget = null;
// original-url 헤더가 없으면 프록시 대상을 알 수 없음 → 400 (NPE 방지) // original-url 헤더가 없으면 프록시 대상을 알 수 없음 → 400 (NPE 방지)
if (url == null || url.trim().isEmpty()) { if (url == null || url.trim().isEmpty()) {
@@ -86,22 +89,24 @@ public class ApiTesterFilter implements Filter {
// 반환하기 위해 try 로 감싼다. // 반환하기 위해 try 로 감싼다.
try { try {
// 게이트웨이 모드에 따라 OAuth 토큰 발급 요청을 mock 또는 실 게이트웨이 forward 로 분기 (DJPGPT0001) // 토큰 발급 분기는 전역 게이트웨이 모드가 아니라 "요청 URL 경로"로 판단한다 (API 별 responseType 기반).
// - mock API → 프론트가 포탈 mock 토큰 경로(/api/v1/oauth/token)로 요청 → 즉시 mock 토큰 발급
// - gw API → 프론트가 실 GW 토큰 경로(token-path)로 요청 → 실 게이트웨이 forward
DjbTestbedGatewayProperty gatewayProperty = ApplicationContextUtil.getContext().getBean(DjbTestbedGatewayProperty.class); DjbTestbedGatewayProperty gatewayProperty = ApplicationContextUtil.getContext().getBean(DjbTestbedGatewayProperty.class);
DjbGatewayMode gatewayMode = gatewayProperty.resolveGatewayMode(); boolean mockTokenRequest = url.contains(DjbTestbedGatewayProperty.PORTAL_MOCK_TOKEN_PATH);
boolean tokenRequest = url.contains(DjbTestbedGatewayProperty.PORTAL_MOCK_TOKEN_PATH) boolean tokenRequest = mockTokenRequest || url.contains(gatewayProperty.tokenPath());
|| url.contains(gatewayProperty.tokenPath());
// 본문은 한 번만 읽어 프록시 forward 와 감사 로그에 함께 사용 (GET 이면 빈 문자열) // 본문은 한 번만 읽어 프록시 forward 와 감사 로그에 함께 사용 (GET 이면 빈 문자열)
String requestBody = readBody(httpServletRequest); String requestBody = readBody(httpServletRequest);
ApiTesterAuditLogger.logRequest(auditId, httpServletRequest, url, gatewayMode.name(), tokenRequest, requestBody); ApiTesterAuditLogger.logRequest(auditId, httpServletRequest, url,
mockTokenRequest ? "MOCK_TOKEN" : "GW", tokenRequest, requestBody);
if (tokenRequest) { if (tokenRequest) {
String body = requestBody; String body = requestBody;
if (gatewayMode == DjbGatewayMode.PORTAL_MOCK) { if (mockTokenRequest) {
auditType = "TOKEN_MOCK"; auditType = "TOKEN_MOCK";
// PortalMock: 고정 mock 토큰 반환 (기존 동작 유지) // mock 응답유형 API: 고정 mock 토큰 즉시 발급 (Secret 검증 없음)
Map<String, String> params = new HashMap<>(); Map<String, String> params = new HashMap<>();
String[] pairs = body.split("&"); String[] pairs = body.split("&");
for (String pair : pairs) { for (String pair : pairs) {
@@ -130,6 +135,12 @@ public class ApiTesterFilter implements Filter {
headers.put("Content-Type", "application/x-www-form-urlencoded"); headers.put("Content-Type", "application/x-www-form-urlencoded");
headers.put("Accept", "application/json"); headers.put("Accept", "application/json");
String target = gatewayProperty.baseUrl() + gatewayProperty.tokenPath(); String target = gatewayProperty.baseUrl() + gatewayProperty.tokenPath();
proxyTarget = target;
if (logger.isDebugEnabled()) {
// client_secret 은 마스킹. body 가 비면 프론트→프록시 전송 유실, client_id 없으면 GW "client not found" 원인.
logger.debug("TOKEN_GW forward - auditId={}, target={}, bodyLen={}, body={}",
auditId, target, body.length(), StringMaskingUtil.maskFormBody(body));
}
String tokenResponse = apiSender.requestPost(target, headers, new HashMap<>(), body); String tokenResponse = apiSender.requestPost(target, headers, new HashMap<>(), body);
response.setContentType("application/json"); response.setContentType("application/json");
@@ -191,6 +202,15 @@ public class ApiTesterFilter implements Filter {
paramMap = extractQueryParams(url); paramMap = extractQueryParams(url);
} }
proxyTarget = targetUri;
if (logger.isDebugEnabled()) {
// GW SERVICE_NOT_FOUND(어댑터 URI 미등록)·AUTH_FAIL 진단용:
// 스펙 식별/응답유형, 실제 forward 대상, 전달 헤더(민감값 마스킹), 본문 길이를 남긴다.
logger.debug("{} forward - auditId={}, apiId={}, apiUrl={}, apiMethod={}, responseType={}, originalUrl={}, target={}, bodyLen={}, headers={}",
auditType, auditId, apiSpecInfoDto.getApiId(), apiSpecInfoDto.getApiUrl(),
apiSpecInfoDto.getApiMethod(), responseType, url, targetUri,
requestBody == null ? 0 : requestBody.length(), maskHeaders(headers));
}
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class); APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
String responseStr; String responseStr;
if ("post".equalsIgnoreCase(apiSpecInfoDto.getApiMethod())) { if ("post".equalsIgnoreCase(apiSpecInfoDto.getApiMethod())) {
@@ -198,23 +218,34 @@ public class ApiTesterFilter implements Filter {
} else { } else {
responseStr = apiSender.requestGet(targetUri, headers, paramMap); responseStr = apiSender.requestGet(targetUri, headers, paramMap);
} }
if (logger.isDebugEnabled()) {
logger.debug("{} response - auditId={}, target={}, respLen={}, preview={}",
auditType, auditId, targetUri,
responseStr == null ? 0 : responseStr.length(), previewOf(responseStr));
}
response.setContentType("application/json"); response.setContentType("application/json");
response.getWriter().println(responseStr); response.getWriter().println(responseStr);
} }
} catch (java.net.SocketTimeoutException e) { } catch (java.net.SocketTimeoutException e) {
// 연결/응답 타임아웃 (djb.gateway.timeout 초과) // 연결/응답 타임아웃 (djb.gateway.timeout 초과)
logger.warn("테스트베드 프록시 타임아웃: {}", e.getMessage()); logger.warn("테스트베드 프록시 타임아웃 - auditId={}, type={}, method={}, originalUrl={}, proxyTarget={}, elapsedMs={}, cause={}: {}",
auditId, auditType, httpServletRequest.getMethod(), url, proxyTarget,
System.currentTimeMillis() - auditStart, e.getClass().getSimpleName(), e.getMessage());
writeJson(response, HttpServletResponse.SC_GATEWAY_TIMEOUT, writeJson(response, HttpServletResponse.SC_GATEWAY_TIMEOUT,
"{\"error\":\"게이트웨이 응답 시간 초과(timeout)\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}"); "{\"error\":\"게이트웨이 응답 시간 초과(timeout)\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
} catch (IOException e) { } catch (IOException e) {
// 연결 실패 등 네트워크 오류 // 연결 실패 등 네트워크 오류 (ConnectException: 대상 다운/포트 닫힘, UnknownHostException: 주소 오기입 등)
logger.error("테스트베드 프록시 호출 실패", e); logger.error("테스트베드 프록시 호출 실패 - auditId={}, type={}, method={}, originalUrl={}, proxyTarget={}, elapsedMs={}, cause={}: {}",
auditId, auditType, httpServletRequest.getMethod(), url, proxyTarget,
System.currentTimeMillis() - auditStart, e.getClass().getSimpleName(), e.getMessage(), e);
writeJson(response, HttpServletResponse.SC_BAD_GATEWAY, writeJson(response, HttpServletResponse.SC_BAD_GATEWAY,
"{\"error\":\"게이트웨이 호출 실패\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}"); "{\"error\":\"게이트웨이 호출 실패\",\"detail\":\"" + escapeJson(e.getClass().getSimpleName() + ": " + e.getMessage()) + "\"}");
} catch (Exception e) { } catch (Exception e) {
// 그 외 예기치 못한 오류도 JSON 으로 반환 // 그 외 예기치 못한 오류도 JSON 으로 반환
logger.error("테스트베드 프록시 처리 오류", e); logger.error("테스트베드 프록시 처리 오류 - auditId={}, type={}, method={}, originalUrl={}, proxyTarget={}, elapsedMs={}, cause={}: {}",
auditId, auditType, httpServletRequest.getMethod(), url, proxyTarget,
System.currentTimeMillis() - auditStart, e.getClass().getSimpleName(), e.getMessage(), e);
writeJson(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, writeJson(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
"{\"error\":\"요청 처리 중 오류\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}"); "{\"error\":\"요청 처리 중 오류\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
} finally { } finally {
@@ -223,7 +254,14 @@ public class ApiTesterFilter implements Filter {
} }
} }
/** 요청 본문 전체를 문자열로 읽는다. */ /**
* 요청 본문 전체를 문자열로 읽는다.
*
* <p>form-urlencoded 요청에서 상위 필터(XSS/CSRF/Multipart 등)가 이미 {@code getParameter*} 로
* 본문 스트림을 소비했으면 {@code getReader()} 는 빈 문자열을 반환한다. 이 경우 토큰 발급 본문
* (grant_type/client_id/client_secret/scope)이 게이트웨이로 전달되지 않아 "client not found" 로
* 실패하므로, 파싱된 파라미터 맵으로 본문을 재구성해 복원한다.</p>
*/
private String readBody(HttpServletRequest request) throws IOException { private String readBody(HttpServletRequest request) throws IOException {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
BufferedReader reader = request.getReader(); BufferedReader reader = request.getReader();
@@ -231,6 +269,39 @@ public class ApiTesterFilter implements Filter {
while ((line = reader.readLine()) != null) { while ((line = reader.readLine()) != null) {
sb.append(line); sb.append(line);
} }
if (sb.length() == 0 && isFormUrlEncoded(request)) {
String rebuilt = rebuildFormBodyFromParams(request);
if (!rebuilt.isEmpty()) {
logger.debug("요청 본문이 비어 파라미터 맵으로 재구성 - body={}", StringMaskingUtil.maskFormBody(rebuilt));
return rebuilt;
}
}
return sb.toString();
}
/** Content-Type 이 application/x-www-form-urlencoded 계열인지. */
private boolean isFormUrlEncoded(HttpServletRequest request) {
String contentType = request.getContentType();
return contentType != null && contentType.toLowerCase().contains("application/x-www-form-urlencoded");
}
/** 파싱된 파라미터 맵을 form-urlencoded 본문 문자열로 재구성 (본문 스트림이 이미 소비된 경우 복원용). */
private String rebuildFormBodyFromParams(HttpServletRequest request) {
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, String[]> entry : request.getParameterMap().entrySet()) {
for (String value : entry.getValue()) {
if (sb.length() > 0) {
sb.append('&');
}
try {
sb.append(URLEncoder.encode(entry.getKey(), "UTF-8"))
.append('=')
.append(URLEncoder.encode(value == null ? "" : value, "UTF-8"));
} catch (java.io.UnsupportedEncodingException e) {
sb.append(entry.getKey()).append('=').append(value == null ? "" : value);
}
}
}
return sb.toString(); return sb.toString();
} }
@@ -250,6 +321,27 @@ public class ApiTesterFilter implements Filter {
} }
/** 상태코드 + JSON 본문 응답. */ /** 상태코드 + JSON 본문 응답. */
/** forward 헤더 debug 출력용 — 민감 헤더(토큰/쿠키 등)는 StringMaskingUtil 로 마스킹. */
private String maskHeaders(Map<String, String> headers) {
StringBuilder sb = new StringBuilder("{");
for (Map.Entry<String, String> e : headers.entrySet()) {
if (sb.length() > 1) {
sb.append(", ");
}
sb.append(e.getKey()).append(':').append(StringMaskingUtil.maskHeaderValue(e.getKey(), e.getValue()));
}
return sb.append('}').toString();
}
/** 응답 body debug 프리뷰 — 앞 300자까지만 (개행 제거). */
private String previewOf(String body) {
if (body == null) {
return "null";
}
String flat = body.replaceAll("\\s+", " ").trim();
return flat.length() > 300 ? flat.substring(0, 300) + "" : flat;
}
private void writeJson(ServletResponse response, int status, String json) throws IOException { private void writeJson(ServletResponse response, int status, String json) throws IOException {
((HttpServletResponse) response).setStatus(status); ((HttpServletResponse) response).setStatus(status);
response.setContentType("application/json"); response.setContentType("application/json");
@@ -13,6 +13,9 @@ import com.eactive.apim.portal.apps.app.dto.AppRequestDTO;
import com.eactive.apim.portal.apps.app.dto.ClientDTO; import com.eactive.apim.portal.apps.app.dto.ClientDTO;
import com.eactive.apim.portal.apps.app.service.AdminGatewayClient; import com.eactive.apim.portal.apps.app.service.AdminGatewayClient;
import com.eactive.apim.portal.apps.app.service.AppServiceFacade; import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
import com.eactive.apim.portal.apps.auth.twofactor.StepUpProtectedPaths;
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties;
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser; import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.ApiServiceHelper; import com.eactive.apim.portal.common.util.ApiServiceHelper;
import com.eactive.apim.portal.common.util.SecurityUtil; import com.eactive.apim.portal.common.util.SecurityUtil;
@@ -49,7 +52,7 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes;
@Slf4j @Slf4j
@Controller @Controller
@RequestMapping("/myapikey") @RequestMapping("/clients")
@RequiredArgsConstructor @RequiredArgsConstructor
@SessionAttributes({"apiKeyRegistration", "apiKeyModification"}) @SessionAttributes({"apiKeyRegistration", "apiKeyModification"})
public class MyAppController { public class MyAppController {
@@ -82,6 +85,8 @@ public class MyAppController {
private final ApiServiceHelper apiServiceHelper; private final ApiServiceHelper apiServiceHelper;
private final FileTypeDetector fileTypeDetector; private final FileTypeDetector fileTypeDetector;
private final AdminGatewayClient adminGatewayClient; private final AdminGatewayClient adminGatewayClient;
private final TwoFactorService twoFactorService;
private final TwoFactorProperties twoFactorProperties;
private static final long MAX_APP_ICON_BYTES = 2L * 1024 * 1024; // 2MB private static final long MAX_APP_ICON_BYTES = 2L * 1024 * 1024; // 2MB
@@ -113,14 +118,14 @@ public class MyAppController {
@Secured("ROLE_APP") @Secured("ROLE_APP")
public ModelAndView appRequestDetail(@RequestParam(value = "id", required = false) String id, Model model) { public ModelAndView appRequestDetail(@RequestParam(value = "id", required = false) String id, Model model) {
if (id == null) { if (id == null) {
return new ModelAndView("redirect:/myapikey"); return new ModelAndView("redirect:/clients");
} }
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser(); PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
AppRequestDTO appRequest = appServiceFacade.getAppRequestById(id, user.getPortalOrg()); AppRequestDTO appRequest = appServiceFacade.getAppRequestById(id, user.getPortalOrg());
if (appRequest == null) { if (appRequest == null) {
return new ModelAndView("redirect:/myapikey"); return new ModelAndView("redirect:/clients");
} }
// API 목록 조회 및 설정 // API 목록 조회 및 설정
@@ -151,14 +156,14 @@ public class MyAppController {
@Secured("ROLE_APP") @Secured("ROLE_APP")
public ModelAndView credentialDetail(@RequestParam(value = "id", required = false) String id, Model model) { public ModelAndView credentialDetail(@RequestParam(value = "id", required = false) String id, Model model) {
if (id == null) { if (id == null) {
return new ModelAndView("redirect:/myapikey"); return new ModelAndView("redirect:/clients");
} }
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser(); PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
ClientDTO apiKey = appServiceFacade.getApiKey(user.getPortalOrg().getId(), id); ClientDTO apiKey = appServiceFacade.getApiKey(user.getPortalOrg().getId(), id);
if (apiKey == null) { if (apiKey == null) {
return new ModelAndView("redirect:/myapikey"); return new ModelAndView("redirect:/clients");
} }
// API 목록에 서비스 정보 추가 // API 목록에 서비스 정보 추가
@@ -318,10 +323,12 @@ public class MyAppController {
} }
/** /**
* Client Secret을 비밀번호 확인 후 1회 노출하고 즉시 DB에서 물리 삭제합니다. * Client Secret을 1회 노출하고 즉시 DB에서 물리 삭제합니다.
* 본인 확인은 step-up 2FA({@link StepUpProtectedPaths#REVEAL_SECRET} 인터셉터 가드)가 담당하며,
* 통과권 없이 진입하면 401(stepUpRequired) 로 차단됩니다.
* 보안 정책상 비밀정보는 최초 1회만 제공됩니다. * 보안 정책상 비밀정보는 최초 1회만 제공됩니다.
* *
* @param requestData clientId, password 포함 * @param requestData clientId 포함
* @return {success, secret} / {success:false, alreadyRevealed:true} / {success:false, message} * @return {success, secret} / {success:false, alreadyRevealed:true} / {success:false, message}
*/ */
@PostMapping("/credential/reveal-secret") @PostMapping("/credential/reveal-secret")
@@ -331,7 +338,6 @@ public class MyAppController {
Map<String, Object> result = new java.util.HashMap<>(); Map<String, Object> result = new java.util.HashMap<>();
String clientId = requestData.get("clientId"); String clientId = requestData.get("clientId");
String password = requestData.get("password");
if (clientId == null || clientId.trim().isEmpty()) { if (clientId == null || clientId.trim().isEmpty()) {
result.put("success", false); result.put("success", false);
@@ -341,14 +347,7 @@ public class MyAppController {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser(); PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
// 1. 본인 확인 (비밀번호) // 소유권 확인 + 1회 노출 + 물리 삭제 (본인 확인은 step-up 2FA 인터셉터가 선행)
if (!appServiceFacade.verifyUserPassword(user, password)) {
result.put("success", false);
result.put("message", "비밀번호가 일치하지 않습니다.");
return result;
}
// 2. 소유권 확인 + 1회 노출 + 물리 삭제
try { try {
String secret = appServiceFacade.revealAndDeleteClientSecret(user.getPortalOrg().getId(), clientId); String secret = appServiceFacade.revealAndDeleteClientSecret(user.getPortalOrg().getId(), clientId);
if (secret == null) { if (secret == null) {
@@ -430,7 +429,7 @@ public class MyAppController {
@Secured("ROLE_API_KEY_REQUEST_VIEW") @Secured("ROLE_API_KEY_REQUEST_VIEW")
public ModelAndView apiRequestDetail(@RequestParam(value = "id", required = false) String id, Model model) { public ModelAndView apiRequestDetail(@RequestParam(value = "id", required = false) String id, Model model) {
if (id == null) { if (id == null) {
return new ModelAndView("redirect:/myapikey/api_key_request/history"); return new ModelAndView("redirect:/clients/api_key_request/history");
} }
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser(); PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
@@ -528,7 +527,7 @@ public class MyAppController {
registration.setIpWhitelistFromString(ipWhitelist); registration.setIpWhitelistFromString(ipWhitelist);
} }
return new ModelAndView("redirect:/myapikey/register/step2"); return new ModelAndView("redirect:/clients/register/step2");
} catch (Exception e) { } catch (Exception e) {
setupStepModel(model, 1); setupStepModel(model, 1);
@@ -593,7 +592,7 @@ public class MyAppController {
// 1단계가 완료되었는지 검증 // 1단계가 완료되었는지 검증
if (!registration.isStep1Complete()) { if (!registration.isStep1Complete()) {
redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요."); redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요.");
return new ModelAndView("redirect:/myapikey/register/step1"); return new ModelAndView("redirect:/clients/register/step1");
} }
// 서비스 카테고리만 가져오기 (API는 AJAX로 로드됨) // 서비스 카테고리만 가져오기 (API는 AJAX로 로드됨)
@@ -624,7 +623,7 @@ public class MyAppController {
} }
// 1단계로 리다이렉트 // 1단계로 리다이렉트
return new ModelAndView("redirect:/myapikey/register/step1"); return new ModelAndView("redirect:/clients/register/step1");
} }
/** /**
@@ -642,7 +641,7 @@ public class MyAppController {
// 1단계가 완료되었는지 검증 // 1단계가 완료되었는지 검증
if (!registration.isStep1Complete()) { if (!registration.isStep1Complete()) {
redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요."); redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요.");
return new ModelAndView("redirect:/myapikey/register/step1"); return new ModelAndView("redirect:/clients/register/step1");
} }
// API 선택은 선택 사항 — 미선택(빈 목록)도 허용한다. // API 선택은 선택 사항 — 미선택(빈 목록)도 허용한다.
@@ -651,7 +650,7 @@ public class MyAppController {
// 등록이 완료되었는지 최종 검증 // 등록이 완료되었는지 최종 검증
if (!registration.isComplete()) { if (!registration.isComplete()) {
redirectAttributes.addFlashAttribute("error", "등록 정보가 완전하지 않습니다."); redirectAttributes.addFlashAttribute("error", "등록 정보가 완전하지 않습니다.");
return new ModelAndView("redirect:/myapikey/register/step1"); return new ModelAndView("redirect:/clients/register/step1");
} }
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser(); PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
@@ -668,12 +667,12 @@ public class MyAppController {
// 성공적으로 완료된 후 세션 초기화 // 성공적으로 완료된 후 세션 초기화
sessionStatus.setComplete(); sessionStatus.setComplete();
return new ModelAndView("redirect:/myapikey/register/step3"); return new ModelAndView("redirect:/clients/register/step3");
} catch (Exception e) { } catch (Exception e) {
// 실패 시 에러 메시지와 함께 step2로 돌아감 // 실패 시 에러 메시지와 함께 step2로 돌아감
redirectAttributes.addFlashAttribute("error", "API Key 등록 중 오류가 발생했습니다. 다시 시도해주세요."); redirectAttributes.addFlashAttribute("error", "API Key 등록 중 오류가 발생했습니다. 다시 시도해주세요.");
return new ModelAndView("redirect:/myapikey/register/step2"); return new ModelAndView("redirect:/clients/register/step2");
} }
} }
@@ -691,7 +690,7 @@ public class MyAppController {
// 직접 접근 방지: Step 2 POST를 거치지 않고 직접 접근한 경우 // 직접 접근 방지: Step 2 POST를 거치지 않고 직접 접근한 경우
if (registrationSuccess == null || !registrationSuccess) { if (registrationSuccess == null || !registrationSuccess) {
return new ModelAndView("redirect:/myapikey"); return new ModelAndView("redirect:/clients");
} }
// 결과 페이지 표시용 속성 설정 // 결과 페이지 표시용 속성 설정
@@ -710,7 +709,7 @@ public class MyAppController {
public String cancelRegistration(SessionStatus sessionStatus) { public String cancelRegistration(SessionStatus sessionStatus) {
// 등록과 관련된 세션 데이터 초기화 // 등록과 관련된 세션 데이터 초기화
sessionStatus.setComplete(); sessionStatus.setComplete();
return "redirect:/myapikey"; return "redirect:/clients";
} }
/** /**
@@ -754,13 +753,15 @@ public class MyAppController {
@Secured("ROLE_API_KEY_REQUEST") @Secured("ROLE_API_KEY_REQUEST")
public ModelAndView modifyStep1( public ModelAndView modifyStep1(
@RequestParam(value = "clientId", required = false) String clientId, @RequestParam(value = "clientId", required = false) String clientId,
@RequestParam(value = "goto", required = false) String gotoStep,
@RequestParam(value = "apiApplyToast", required = false) String apiApplyToast,
@ModelAttribute("apiKeyModification") ApiKeyRegistrationDTO modification, @ModelAttribute("apiKeyModification") ApiKeyRegistrationDTO modification,
SessionStatus sessionStatus, SessionStatus sessionStatus,
Model model, Model model,
RedirectAttributes redirectAttributes) { RedirectAttributes redirectAttributes) {
if (clientId == null) { if (clientId == null) {
return new ModelAndView("redirect:/myapikey"); return new ModelAndView("redirect:/clients");
} }
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser(); PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
@@ -768,7 +769,7 @@ public class MyAppController {
// 기존 API Key 정보 조회 // 기존 API Key 정보 조회
ClientDTO apiKey = appServiceFacade.getApiKey(user.getPortalOrg().getId(), clientId); ClientDTO apiKey = appServiceFacade.getApiKey(user.getPortalOrg().getId(), clientId);
if (apiKey == null) { if (apiKey == null) {
return new ModelAndView("redirect:/myapikey"); return new ModelAndView("redirect:/clients");
} }
// 새로운 수정 세션 시작시에만 초기화 // 새로운 수정 세션 시작시에만 초기화
@@ -810,6 +811,16 @@ public class MyAppController {
model.addAttribute("apiKeyModification", modification); model.addAttribute("apiKeyModification", modification);
} }
// API 신청 절차: 클라이언트 1건 보유 시 API 상세에서 goto=apis 로 진입
// → 세션 초기화 후 API 선택(2단계)로 직행 (기본 정보 미완성이면 step2 가드가 1단계로 되돌림)
if ("apis".equals(gotoStep)) {
String redirectUrl = "redirect:/clients/modify/step2";
if (apiApplyToast != null && apiApplyToast.matches("[a-zA-Z_-]{1,30}")) {
redirectUrl += "?apiApplyToast=" + apiApplyToast;
}
return new ModelAndView(redirectUrl);
}
// Step 모델 설정 // Step 모델 설정
setupStepModel(model, 1); setupStepModel(model, 1);
model.addAttribute("userOrg", user.getPortalOrg()); model.addAttribute("userOrg", user.getPortalOrg());
@@ -861,7 +872,7 @@ public class MyAppController {
modification.setIpWhitelistFromString(ipWhitelist); modification.setIpWhitelistFromString(ipWhitelist);
} }
return new ModelAndView("redirect:/myapikey/modify/step2"); return new ModelAndView("redirect:/clients/modify/step2");
} catch (Exception e) { } catch (Exception e) {
setupStepModel(model, 1); setupStepModel(model, 1);
@@ -883,7 +894,7 @@ public class MyAppController {
// 1단계가 완료되었는지 검증 // 1단계가 완료되었는지 검증
if (!modification.isStep1Complete()) { if (!modification.isStep1Complete()) {
redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요."); redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요.");
return new ModelAndView("redirect:/myapikey/modify/step1?clientId=" + modification.getClientId()); return new ModelAndView("redirect:/clients/modify/step1?clientId=" + modification.getClientId());
} }
// 서비스 카테고리와 API 목록 가져오기 // 서비스 카테고리와 API 목록 가져오기
@@ -893,6 +904,8 @@ public class MyAppController {
setupStepModel(model, 2); setupStepModel(model, 2);
model.addAttribute("apiServices", apiServices); model.addAttribute("apiServices", apiServices);
model.addAttribute("modification", modification); model.addAttribute("modification", modification);
// 최종 반영(저장) 직전 2FA 필요 여부 → 폼 JS 분기용
model.addAttribute("twofaRequired", isAppModifyTwofaRequired());
return new ModelAndView(API_KEY_MODIFY_STEP2); return new ModelAndView(API_KEY_MODIFY_STEP2);
} }
@@ -914,7 +927,7 @@ public class MyAppController {
} }
// 1단계로 리다이렉트 // 1단계로 리다이렉트
return new ModelAndView("redirect:/myapikey/modify/step1?clientId=" + modification.getClientId()); return new ModelAndView("redirect:/clients/modify/step1?clientId=" + modification.getClientId());
} }
/** /**
@@ -927,18 +940,19 @@ public class MyAppController {
@RequestParam(value = "selectedApis", required = false) List<String> selectedApis, @RequestParam(value = "selectedApis", required = false) List<String> selectedApis,
@ModelAttribute("apiKeyModification") ApiKeyRegistrationDTO modification, @ModelAttribute("apiKeyModification") ApiKeyRegistrationDTO modification,
SessionStatus sessionStatus, SessionStatus sessionStatus,
HttpSession session,
RedirectAttributes redirectAttributes) { RedirectAttributes redirectAttributes) {
// 1단계가 완료되었는지 검증 // 1단계가 완료되었는지 검증
if (!modification.isStep1Complete()) { if (!modification.isStep1Complete()) {
redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요."); redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요.");
return new ModelAndView("redirect:/myapikey/modify/step1?clientId=" + modification.getClientId()); return new ModelAndView("redirect:/clients/modify/step1?clientId=" + modification.getClientId());
} }
// API 선택 검증 // API 선택 검증
if (selectedApis == null || selectedApis.isEmpty()) { if (selectedApis == null || selectedApis.isEmpty()) {
redirectAttributes.addFlashAttribute("error", "최소 1개 이상의 API를 선택해주세요."); redirectAttributes.addFlashAttribute("error", "최소 1개 이상의 API를 선택해주세요.");
return new ModelAndView("redirect:/myapikey/modify/step2"); return new ModelAndView("redirect:/clients/modify/step2");
} }
// 선택된 API를 세션에 저장 // 선택된 API를 세션에 저장
@@ -947,7 +961,15 @@ public class MyAppController {
// 등록이 완료되었는지 최종 검증 // 등록이 완료되었는지 최종 검증
if (!modification.isComplete()) { if (!modification.isComplete()) {
redirectAttributes.addFlashAttribute("error", "수정 정보가 완전하지 않습니다."); redirectAttributes.addFlashAttribute("error", "수정 정보가 완전하지 않습니다.");
return new ModelAndView("redirect:/myapikey/modify/step1?clientId=" + modification.getClientId()); return new ModelAndView("redirect:/clients/modify/step1?clientId=" + modification.getClientId());
}
// 반영 직전 2FA: 통과권이 없으면 커밋하지 않고 step2 로 되돌린다(프론트가 먼저 2FA 팝업을 띄운다).
// 진입(step1)이 아닌 최종 반영 시점에만 인증을 요구해 다단계 진행 중 중복 인증을 막는다.
if (isAppModifyTwofaRequired()
&& !twoFactorService.consumeStepUpPass(session, StepUpProtectedPaths.APP_MODIFY_COMMIT)) {
redirectAttributes.addFlashAttribute("error", "추가 인증(2FA) 후 다시 시도해 주세요.");
return new ModelAndView("redirect:/clients/modify/step2");
} }
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser(); PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
@@ -964,12 +986,12 @@ public class MyAppController {
// 성공적으로 완료된 후 세션 초기화 // 성공적으로 완료된 후 세션 초기화
sessionStatus.setComplete(); sessionStatus.setComplete();
return new ModelAndView("redirect:/myapikey/modify/step3"); return new ModelAndView("redirect:/clients/modify/step3");
} catch (Exception e) { } catch (Exception e) {
// 실패 시 에러 메시지와 함께 step2로 돌아감 // 실패 시 에러 메시지와 함께 step2로 돌아감
redirectAttributes.addFlashAttribute("error", "API Key 수정 요청 중 오류가 발생했습니다. 다시 시도해주세요."); redirectAttributes.addFlashAttribute("error", "API Key 수정 요청 중 오류가 발생했습니다. 다시 시도해주세요.");
return new ModelAndView("redirect:/myapikey/modify/step2"); return new ModelAndView("redirect:/clients/modify/step2");
} }
} }
@@ -986,7 +1008,7 @@ public class MyAppController {
// 직접 접근 방지: Step 2 POST를 거치지 않고 직접 접근한 경우 // 직접 접근 방지: Step 2 POST를 거치지 않고 직접 접근한 경우
if (modificationComplete == null || !modificationComplete) { if (modificationComplete == null || !modificationComplete) {
return new ModelAndView("redirect:/myapikey"); return new ModelAndView("redirect:/clients");
} }
// 결과 페이지 표시용 속성 설정 // 결과 페이지 표시용 속성 설정
@@ -1015,12 +1037,18 @@ public class MyAppController {
// clientId가 있으면 상세 페이지로, 없으면 목록으로 // clientId가 있으면 상세 페이지로, 없으면 목록으로
if (clientId != null && !clientId.isEmpty()) { if (clientId != null && !clientId.isEmpty()) {
return "redirect:/myapikey/credential_detail?id=" + clientId; return "redirect:/clients/credential_detail?id=" + clientId;
} else { } else {
return "redirect:/myapikey"; return "redirect:/clients";
} }
} }
/** 앱 수정 최종 반영 직전 2FA(step-up)가 현재 활성인지 — 전체/지점 스위치 AND */
private boolean isAppModifyTwofaRequired() {
return twoFactorProperties.isStepUpEnabled()
&& twoFactorProperties.isStepUpPointEnabled(StepUpProtectedPaths.APP_MODIFY_COMMIT);
}
} }
@@ -73,8 +73,8 @@ public class AppServiceFacade {
List<AppRequest> appRequests = appRequestRepository.findAllByOrgAndTypeIsInAndApproval_ApprovalStatusIn(portalOrg, types, List<AppRequest> appRequests = appRequestRepository.findAllByOrgAndTypeIsInAndApproval_ApprovalStatusIn(portalOrg, types,
Arrays.asList(new ProcessingState(), new RequestedState())); Arrays.asList(new ProcessingState(), new RequestedState()));
// 승인정보(approval) 없는 신청도 목록에 노출한다. (사용자가 직접 삭제 가능) // 승인정보(approval) 없는 신청도 목록에 노출`한다. (사용자가 직접 삭제 가능)
appRequests.addAll(appRequestRepository.findAllByOrgAndTypeIsInAndApprovalIsNull(portalOrg, types)); appRequests.addAll(appRequestRepository .findAllByOrgAndTypeIsInAndApprovalIsNull(portalOrg, types));
return appRequests; return appRequests;
} }
@@ -11,7 +11,8 @@ import java.util.Set;
* <p>검증 레벨(완화 정책)</p> * <p>검증 레벨(완화 정책)</p>
* <ul> * <ul>
* <li>{@link Level#TWO_FACTOR} — 공통 2FA 팝업(휴대폰/이메일 인증번호). 진입 인터셉터 또는 * <li>{@link Level#TWO_FACTOR} — 공통 2FA 팝업(휴대폰/이메일 인증번호). 진입 인터셉터 또는
* AJAX 401 신호로 유도. 예: Secret 조회/앱 해지/앱 정보수정.</li> * AJAX 401 신호로 유도. 예: Secret 조회/앱 해지. 앱 정보수정은 최종 반영(commit)
* 직전에 컨트롤러가 통과권을 요구한다(다단계 진행 중 중복 인증 방지).</li>
* <li>{@link Level#PASSWORD} — 현재 비밀번호 재확인만 요구(2FA 없음). 별도 확인 페이지 * <li>{@link Level#PASSWORD} — 현재 비밀번호 재확인만 요구(2FA 없음). 별도 확인 페이지
* ({@code /auth/stepup/password})로 유도. 예: 내 정보 변경({@code /mypage}).</li> * ({@code /auth/stepup/password})로 유도. 예: 내 정보 변경({@code /mypage}).</li>
* </ul> * </ul>
@@ -40,15 +41,17 @@ public final class StepUpProtectedPaths {
} }
/** Secret 키 조회 (AJAX POST) */ /** Secret 키 조회 (AJAX POST) */
public static final String REVEAL_SECRET = "/myapikey/credential/reveal-secret"; public static final String REVEAL_SECRET = "/clients/credential/reveal-secret";
/** 앱 해지 신청 (AJAX POST) */ /** 앱 해지 신청 (AJAX POST) */
public static final String APP_KEY_DELETE = "/myapikey/api_key_delete"; public static final String APP_KEY_DELETE = "/clients/api_key_delete";
/** 앱 정보 수정 페이지 진입 (GET) */ /** 앱 정보 수정 최종 반영(commit, POST /modify/step2) — 반영 직전 2FA. 진입/중간 단계는 가드하지 않음 */
public static final String APP_MODIFY_STEP1 = "/myapikey/modify/step1"; public static final String APP_MODIFY_COMMIT = "/clients/modify/step2";
/** 개인정보 변경 페이지 진입 (GET, 정확 일치) — PASSWORD 레벨 */ /** 개인정보 변경 페이지 진입 (GET, 정확 일치) — PASSWORD 레벨 */
public static final String MYPAGE = "/mypage"; public static final String MYPAGE = "/mypage";
/** 비밀번호 변경 반영(commit, POST) — 반영 직전 2FA. 진입(GET)은 가드하지 않음 */ /** 비밀번호 변경 반영(commit, POST) — 반영 직전 2FA. 진입(GET)은 가드하지 않음 */
public static final String PASSWORD_CHANGE = "/password/change"; public static final String PASSWORD_CHANGE = "/password/change";
/** 회원 탈퇴 반영(commit, POST) — 반영 직전 2FA. 팝업(사유 입력) 후 프론트가 2FA 를 띄운다 */
public static final String WITHDRAW = "/withdraw";
/** PTL_PROPERTY 지점 키 접두 (전체 스위치 two-factor.stepup.enabled 와 구분) */ /** PTL_PROPERTY 지점 키 접두 (전체 스위치 two-factor.stepup.enabled 와 구분) */
private static final String KEY_PREFIX = "two-factor.stepup."; private static final String KEY_PREFIX = "two-factor.stepup.";
@@ -63,26 +66,31 @@ public final class StepUpProtectedPaths {
static { static {
Map<String, String> keys = new LinkedHashMap<>(); Map<String, String> keys = new LinkedHashMap<>();
keys.put(REVEAL_SECRET, KEY_PREFIX + "reveal-secret"); keys.put(REVEAL_SECRET, KEY_PREFIX + "reveal-secret");
keys.put(APP_MODIFY_STEP1, KEY_PREFIX + "app-modify"); keys.put(APP_MODIFY_COMMIT, KEY_PREFIX + "app-modify");
keys.put(APP_KEY_DELETE, KEY_PREFIX + "app-delete"); keys.put(APP_KEY_DELETE, KEY_PREFIX + "app-delete");
keys.put(MYPAGE, KEY_PREFIX + "mypage"); keys.put(MYPAGE, KEY_PREFIX + "mypage");
keys.put(PASSWORD_CHANGE, KEY_PREFIX + "password-change"); keys.put(PASSWORD_CHANGE, KEY_PREFIX + "password-change");
keys.put(WITHDRAW, KEY_PREFIX + "withdraw");
PATH_TO_KEY = Collections.unmodifiableMap(keys); PATH_TO_KEY = Collections.unmodifiableMap(keys);
Map<String, Level> levels = new LinkedHashMap<>(); Map<String, Level> levels = new LinkedHashMap<>();
levels.put(REVEAL_SECRET, Level.TWO_FACTOR); levels.put(REVEAL_SECRET, Level.TWO_FACTOR);
levels.put(APP_MODIFY_STEP1, Level.TWO_FACTOR); levels.put(APP_MODIFY_COMMIT, Level.TWO_FACTOR);
levels.put(APP_KEY_DELETE, Level.TWO_FACTOR); levels.put(APP_KEY_DELETE, Level.TWO_FACTOR);
levels.put(MYPAGE, Level.PASSWORD); levels.put(MYPAGE, Level.PASSWORD);
levels.put(PASSWORD_CHANGE, Level.TWO_FACTOR); levels.put(PASSWORD_CHANGE, Level.TWO_FACTOR);
levels.put(WITHDRAW, Level.TWO_FACTOR);
PATH_TO_LEVEL = Collections.unmodifiableMap(levels); PATH_TO_LEVEL = Collections.unmodifiableMap(levels);
// 인터셉터 진입 자동 차단: 2FA 레벨 중 "진입 시점" 보호가 필요한 경로만. // 인터셉터 진입 자동 차단: 2FA 레벨 중 "진입 시점" 보호가 필요한 경로만.
// - PASSWORD_CHANGE 는 반영(POST commit) 직전에 컨트롤러가 통과권을 요구 → 제외 // - PASSWORD_CHANGE 는 반영(POST commit) 직전에 컨트롤러가 통과권을 요구 → 제외
// - APP_MODIFY_COMMIT 도 동일 — 다단계(step1→step2) 진행 중 중복 인증을 막기 위해
// 최종 반영 직전에만 컨트롤러가 통과권을 요구 → 제외
// - MYPAGE 는 별도 확인 페이지로 컨트롤러가 유도(PASSWORD 레벨) → 제외 // - MYPAGE 는 별도 확인 페이지로 컨트롤러가 유도(PASSWORD 레벨) → 제외
// - WITHDRAW 는 팝업(사유 입력)→2FA→제출 순서로 프론트가 유도하고
// 컨트롤러가 커밋 직전 통과권을 요구 → 제외
Set<String> guarded = new java.util.LinkedHashSet<>(); Set<String> guarded = new java.util.LinkedHashSet<>();
guarded.add(REVEAL_SECRET); guarded.add(REVEAL_SECRET);
guarded.add(APP_MODIFY_STEP1);
guarded.add(APP_KEY_DELETE); guarded.add(APP_KEY_DELETE);
INTERCEPTOR_GUARDED = Collections.unmodifiableSet(guarded); INTERCEPTOR_GUARDED = Collections.unmodifiableSet(guarded);
} }
@@ -1,6 +1,7 @@
package com.eactive.apim.portal.apps.auth.twofactor; package com.eactive.apim.portal.apps.auth.twofactor;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService; import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -20,6 +21,7 @@ public class TwoFactorProperties {
public static final String GROUP = "Portal"; public static final String GROUP = "Portal";
public static final String KEY_LOGIN_ENABLED = "two-factor.login.enabled"; public static final String KEY_LOGIN_ENABLED = "two-factor.login.enabled";
public static final String KEY_LOGIN_TARGET_ROLES = "two-factor.login.target-roles";
public static final String KEY_TTL_SECONDS = "two-factor.ttl.seconds"; public static final String KEY_TTL_SECONDS = "two-factor.ttl.seconds";
public static final String KEY_ATTEMPT_LIMIT = "two-factor.attempt.limit"; public static final String KEY_ATTEMPT_LIMIT = "two-factor.attempt.limit";
public static final String KEY_TEST_NOTICE_ENABLED = "two-factor.test-notice.enabled"; public static final String KEY_TEST_NOTICE_ENABLED = "two-factor.test-notice.enabled";
@@ -32,6 +34,33 @@ public class TwoFactorProperties {
return parseBool(resolve(KEY_LOGIN_ENABLED, "false", "로그인 2차 인증 활성화 여부 (true/false)")); return parseBool(resolve(KEY_LOGIN_ENABLED, "false", "로그인 2차 인증 활성화 여부 (true/false)"));
} }
/**
* 로그인 2FA 적용 대상 역할인지 여부.
* 프로퍼티 값: 쉼표 구분 RoleCode 목록(예: {@code ROLE_CORP_MANAGER,ROLE_CORP_USER})
* 또는 {@code ALL}(전체 대상). 기본값은 법인관리자만.
* 미기재 역할은 로그인 2FA 를 건너뛴다(전체 스위치 {@link #isLoginEnabled()}와 AND 동작).
*/
public boolean isLoginTargetRole(RoleCode roleCode) {
if (roleCode == null) {
roleCode = RoleCode.ROLE_USER;
}
String value = resolve(KEY_LOGIN_TARGET_ROLES, RoleCode.ROLE_CORP_MANAGER.name(),
"로그인 2차 인증 대상 역할 (쉼표구분: ROLE_USER,ROLE_CORP_USER,ROLE_CORP_MANAGER / 전체: ALL)");
if (value == null || value.trim().isEmpty()) {
return false;
}
String trimmed = value.trim();
if ("ALL".equalsIgnoreCase(trimmed)) {
return true;
}
for (String token : trimmed.split(",")) {
if (roleCode.name().equalsIgnoreCase(token.trim())) {
return true;
}
}
return false;
}
/** step-up(민감기능) 2FA 전체 활성화 여부(마스터 스위치) */ /** step-up(민감기능) 2FA 전체 활성화 여부(마스터 스위치) */
public boolean isStepUpEnabled() { public boolean isStepUpEnabled() {
return parseBool(resolve(KEY_STEPUP_ENABLED, "false", "민감기능 추가 인증(step-up) 전체 활성화 여부 (true/false)")); return parseBool(resolve(KEY_STEPUP_ENABLED, "false", "민감기능 추가 인증(step-up) 전체 활성화 여부 (true/false)"));
@@ -0,0 +1,51 @@
package com.eactive.apim.portal.apps.login.controller;
import com.eactive.apim.portal.apps.login.service.DuplicateLoginService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.util.HashMap;
import java.util.Map;
/**
* 동시 접속(중복 세션) 확인 대기 상태(로그인 2FA off 경로)의 확정/취소 API.
*
* <p>대기 상태는 1차 인증(ID/PW) 성공 후 SuccessHandler 만 세팅하므로, 이 엔드포인트는
* 비밀번호 검증을 통과한 세션에서만 의미가 있다. 모든 POST 는 세션 기반
* CSRF(X-XSRF-TOKEN) 보호를 받는다.</p>
*/
@RestController
@RequestMapping("/login/duplicate")
@RequiredArgsConstructor
public class DuplicateLoginController {
private final DuplicateLoginService duplicateLoginService;
/** 기존 접속 해제 확인 → 로그인 확정. 무효(만료/상태 변경) 시 재로그인 안내 */
@PostMapping("/confirm")
public Map<String, Object> confirm(HttpServletRequest request, HttpSession session) {
Map<String, Object> result = new HashMap<>();
String redirect = duplicateLoginService.confirm(request, session);
if (redirect != null) {
result.put("valid", true);
result.put("redirect", redirect);
} else {
result.put("valid", false);
result.put("message", "로그인 확인이 만료되었습니다. 다시 로그인해주세요.");
}
return result;
}
/** 확인 취소 — 로그인 포기(익명 유지) */
@PostMapping("/cancel")
public Map<String, Object> cancel(HttpSession session) {
duplicateLoginService.cancel(session);
Map<String, Object> result = new HashMap<>();
result.put("valid", true);
return result;
}
}
@@ -1,6 +1,7 @@
package com.eactive.apim.portal.apps.login.controller; package com.eactive.apim.portal.apps.login.controller;
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService; import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService;
import com.eactive.apim.portal.apps.login.service.DuplicateLoginService;
import com.eactive.apim.portal.common.exception.PortalRedirectException; import com.eactive.apim.portal.common.exception.PortalRedirectException;
import com.eactive.apim.portal.common.pagerouter.PageHandler; import com.eactive.apim.portal.common.pagerouter.PageHandler;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
@@ -24,9 +25,11 @@ import static com.eactive.apim.portal.apps.login.constants.LoginConstants.LOGIN_
public class LoginHandler implements PageHandler { public class LoginHandler implements PageHandler {
private final TwoFactorService twoFactorService; private final TwoFactorService twoFactorService;
private final DuplicateLoginService duplicateLoginService;
public LoginHandler(TwoFactorService twoFactorService) { public LoginHandler(TwoFactorService twoFactorService, DuplicateLoginService duplicateLoginService) {
this.twoFactorService = twoFactorService; this.twoFactorService = twoFactorService;
this.duplicateLoginService = duplicateLoginService;
} }
/** /**
@@ -55,7 +58,24 @@ public class LoginHandler implements PageHandler {
// 로그인 2FA 대기 상태면(1차 인증 통과 후) 추가 인증 팝업 자동 오픈 플래그를 내려준다. // 로그인 2FA 대기 상태면(1차 인증 통과 후) 추가 인증 팝업 자동 오픈 플래그를 내려준다.
// pending 중에는 아직 익명이므로 아래 인증자 리다이렉트에 걸리지 않는다. // pending 중에는 아직 익명이므로 아래 인증자 리다이렉트에 걸리지 않는다.
model.addAttribute("twoFactorPending", twoFactorService.hasPendingLogin(session)); boolean twoFactorPending = twoFactorService.hasPendingLogin(session);
model.addAttribute("twoFactorPending", twoFactorPending);
// 동시 접속 안내 — 반드시 1차 인증 통과 후(2FA pending 또는 중복 확인 대기)에만 노출한다.
// - 2FA on: 확인 후 2FA 팝업 진행(취소 시 /auth/2fa/cancel)
// - 2FA off: 확인 후 /login/duplicate/confirm 으로 확정
String pendingLoginId = null;
boolean duplicateConfirmPending = false;
if (twoFactorPending) {
pendingLoginId = (String) session.getAttribute(TwoFactorService.ATTR_PENDING_LOGIN_ID);
} else if (duplicateLoginService.hasPending(session)
&& "1".equals(httpRequest.getParameter("duplicate"))) {
pendingLoginId = duplicateLoginService.pendingLoginId(session);
duplicateConfirmPending = true;
}
model.addAttribute("duplicateConfirmPending", duplicateConfirmPending);
model.addAttribute("duplicateInfo",
pendingLoginId != null ? duplicateLoginService.activeSessionInfo(pendingLoginId) : null);
// 이미 인증된 사용자인지 확인 // 이미 인증된 사용자인지 확인
Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
@@ -0,0 +1,185 @@
package com.eactive.apim.portal.apps.login.service;
import com.eactive.apim.portal.apps.login.constants.LoginType;
import com.eactive.apim.portal.apps.session.entity.UserSession;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
/**
* 로그인 시 동시 접속(중복 세션) 확인 처리.
*
* <p>중복 확인은 반드시 <b>1차 인증(ID/PW) 성공 후</b>에만 수행한다. 비밀번호 검증 전에
* 노출하면 임의 계정의 접속 여부·IP 가 인증 없이 조회되는 정보 노출이 된다(기존
* {@code /api/session/check-duplicate} 사전 체크 방식의 문제).</p>
*
* <p>두 경로에서 쓰인다:
* <ul>
* <li>로그인 2FA on — 2FA pending 상태의 로그인 페이지가 {@link #activeSessionInfo(String)}
* 로 안내 정보를 내려주고, 확인 후 2FA 팝업으로 진행(취소 시 {@code /auth/2fa/cancel}).</li>
* <li>로그인 2FA off — SuccessHandler 가 확정을 보류하고 {@link #begin} 으로 대기 상태 전환.
* 사용자가 확인하면 {@link #confirm} 이 인증을 확정한다(기존 세션은
* {@link LoginFinalizer#finalizeLogin} 의 forceLogoutOtherSessions 로 해제).</li>
* </ul></p>
*/
@Service
@Transactional
@RequiredArgsConstructor
public class DuplicateLoginService {
/** 동시 접속 확인 대기 - 대상 사용자 id (2FA off 경로) */
public static final String ATTR_PENDING_USER_ID = "DUP_PENDING_USER_ID";
/** 동시 접속 확인 대기 - loginId */
public static final String ATTR_PENDING_LOGIN_ID = "DUP_PENDING_LOGIN_ID";
/** 동시 접속 확인 대기 - 진입 시각 */
public static final String ATTR_PENDING_AT = "DUP_PENDING_AT";
/** 확인 대기 유효시간(초). 초과 시 처음부터 재로그인 */
public static final int PENDING_TTL_SECONDS = 120;
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final UserSessionService userSessionService;
private final PortalUserRepository portalUserRepository;
private final PortalUserAuthService portalUserAuthService;
private final LoginFinalizer loginFinalizer;
/** 해당 계정의 활성 세션(다른 곳 접속) 존재 여부 */
@Transactional(readOnly = true)
public boolean hasActiveSession(String loginId) {
return activeSession(loginId).isPresent();
}
/**
* 활성 세션 안내 정보(마스킹 IP·접속 시각). 없으면 null.
* 로그인 페이지 확인 팝업 표시용 — 1차 인증 통과 후에만 호출해야 한다.
*/
@Transactional(readOnly = true)
public Map<String, String> activeSessionInfo(String loginId) {
Optional<UserSession> active = activeSession(loginId);
if (!active.isPresent()) {
return null;
}
Map<String, String> info = new HashMap<>();
info.put("ipAddress", maskIpAddress(active.get().getIpAddress()));
info.put("loginTime", active.get().getLoginTime().format(TIME_FORMATTER));
return info;
}
// =========================================================================
// 2FA off 경로: 확정 보류 → 확인 → 확정
// =========================================================================
/** 1차 인증 성공 사용자를 동시 접속 확인 대기 상태로 세팅한다. (SecurityContext 클리어는 호출부 책임) */
public void begin(HttpSession session, PortalUser user) {
session.setAttribute(ATTR_PENDING_USER_ID, user.getId());
session.setAttribute(ATTR_PENDING_LOGIN_ID, user.getLoginId());
session.setAttribute(ATTR_PENDING_AT, LocalDateTime.now());
}
public boolean hasPending(HttpSession session) {
return session != null && session.getAttribute(ATTR_PENDING_USER_ID) != null;
}
public String pendingLoginId(HttpSession session) {
return session == null ? null : (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
}
/**
* 동시 접속 확인 후 로그인 확정. 대기 상태가 유효하면 인증을 세팅하고 최종 이동 URL 을
* 반환한다(기존 세션 해제 포함). 무효(만료/상태 변경)면 null — 재로그인 필요.
*/
public String confirm(HttpServletRequest request, HttpSession session) {
String userId = (String) session.getAttribute(ATTR_PENDING_USER_ID);
String loginId = (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
Object at = session.getAttribute(ATTR_PENDING_AT);
cancel(session); // 1회용 — 성공/실패 무관하게 대기 상태는 소멸
if (userId == null || !(at instanceof LocalDateTime)
|| ((LocalDateTime) at).plusSeconds(PENDING_TTL_SECONDS).isBefore(LocalDateTime.now())) {
return null;
}
PortalUser user = portalUserRepository.findById(userId).orElse(null);
if (user == null || !isLoginStillAllowed(user)) {
return null;
}
// 프로그래매틱 인증 확정 (요청 종료 시 SecurityContextPersistenceFilter 가 세션에 저장)
PortalAuthenticatedUser authUser = portalUserAuthService.buildAuthenticatedUser(user);
UsernamePasswordAuthenticationToken token =
new UsernamePasswordAuthenticationToken(authUser, null, authUser.getAuthorities());
token.setDetails(authUser);
SecurityContextHolder.getContext().setAuthentication(token);
return loginFinalizer.finalizeLogin(user, loginId, request, LoginType.NORMAL);
}
/** 확인 취소 — 대기 상태 정리(익명 유지) */
public void cancel(HttpSession session) {
session.removeAttribute(ATTR_PENDING_USER_ID);
session.removeAttribute(ATTR_PENDING_LOGIN_ID);
session.removeAttribute(ATTR_PENDING_AT);
}
// =========================================================================
// 내부 helper
// =========================================================================
private Optional<UserSession> activeSession(String loginId) {
if (loginId == null) {
return Optional.empty();
}
return userSessionService.getActiveSession(loginId.toLowerCase());
}
/** 1차 인증~확인 사이 계정 상태 변경 방어 (TwoFactorService.revalidateLoginState 와 동일 기준) */
private boolean isLoginStillAllowed(PortalUser user) {
if ("Y".equalsIgnoreCase(user.getAccountLockYn())) {
return false;
}
if (PortalUserEnums.UserStatus.ADMINBLOCK.equals(user.getUserStatus())) {
return false;
}
if (PortalUserEnums.ApprovalStatus.PENDING.equals(user.getApprovalStatus())) {
return false;
}
return user.getPortalOrg() == null
|| PortalOrgEnums.ApprovalStatus.COMPLETED.equals(user.getPortalOrg().getApprovalStatus());
}
/**
* IP 주소 마스킹 (3번째 옥텟을 ***로 치환). 예: 192.168.240.178 → 192.168.***.178
*/
private static String maskIpAddress(String ip) {
if (ip == null || ip.isEmpty()) {
return "알 수 없음";
}
String[] parts = ip.split("\\.");
if (parts.length == 4) {
return parts[0] + "." + parts[1] + ".***." + parts[3];
}
// IPv6 등 다른 형식은 일부만 표시
if (ip.length() > 8) {
return ip.substring(0, 4) + "****" + ip.substring(ip.length() - 4);
}
return "***";
}
}
@@ -118,6 +118,14 @@ public class LoginFinalizer {
} }
} }
// 법인 사용자(관리자/개발자) 로그인 시, 홈 최초 진입에서 클라이언트 신규 신청 유도 팝업을 1회 노출하도록 마킹한다.
// 실제 클라이언트(승인+요청중) 보유 여부 판정과 1회 소비는 IndexController 가 담당한다.
PortalUserEnums.RoleCode roleCode = user.getRoleCode();
if (roleCode == PortalUserEnums.RoleCode.ROLE_CORP_MANAGER
|| roleCode == PortalUserEnums.RoleCode.ROLE_CORP_USER) {
session.setAttribute("checkClientRegister", true);
}
// 중복 로그인 방지: 기존 세션 강제 로그아웃 + 현재 세션 등록 // 중복 로그인 방지: 기존 세션 강제 로그아웃 + 현재 세션 등록
String clientIp = HttpRequestUtil.getClientIpAddress(request); String clientIp = HttpRequestUtil.getClientIpAddress(request);
userSessionService.forceLogoutOtherSessions(normalizedUsername, sessionId); userSessionService.forceLogoutOtherSessions(normalizedUsername, sessionId);
@@ -1,10 +1,14 @@
package com.eactive.apim.portal.apps.main.controller; package com.eactive.apim.portal.apps.main.controller;
import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceDTO; import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceDTO;
import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
import com.eactive.apim.portal.apps.main.dto.IndexStatisticsDTO; import com.eactive.apim.portal.apps.main.dto.IndexStatisticsDTO;
import com.eactive.apim.portal.apps.main.service.IndexStatisticsService; import com.eactive.apim.portal.apps.main.service.IndexStatisticsService;
import com.eactive.apim.portal.apps.main.service.MainApiFacade; import com.eactive.apim.portal.apps.main.service.MainApiFacade;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import java.util.List; import java.util.List;
import javax.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.Model; import org.springframework.ui.Model;
@@ -17,6 +21,7 @@ public class IndexController {
private final MainApiFacade mainApiFacade; private final MainApiFacade mainApiFacade;
private final IndexStatisticsService indexStatisticsService; private final IndexStatisticsService indexStatisticsService;
private final AppServiceFacade appServiceFacade;
/** /**
* 메인 페이지 API는 Portal Property 에 main.service.list 에 등록된 그룹을 기준으로 API를 조회함. * 메인 페이지 API는 Portal Property 에 main.service.list 에 등록된 그룹을 기준으로 API를 조회함.
@@ -29,7 +34,7 @@ public class IndexController {
* @return * @return
*/ */
@GetMapping("/") @GetMapping("/")
public String index(Model model) { public String index(Model model, HttpSession session) {
List<ApiServiceDTO> services = mainApiFacade.getOpenApiServices(); List<ApiServiceDTO> services = mainApiFacade.getOpenApiServices();
List<String> hashTags = mainApiFacade.getHashTags(); List<String> hashTags = mainApiFacade.getHashTags();
@@ -39,9 +44,37 @@ public class IndexController {
addAttributesToModel(model, services, hashTags, statistics); addAttributesToModel(model, services, hashTags, statistics);
resolveClientRegisterNudge(model, session);
return "apps/main/index"; return "apps/main/index";
} }
/**
* 법인 사용자(관리자/개발자) 로그인 직후, 클라이언트(승인+요청중)가 하나도 없으면
* 클라이언트 신규 신청 유도 팝업 노출 플래그를 세팅한다.
*
* <p>노출 시점은 {@code LoginFinalizer} 가 로그인 시 세팅한 {@code checkClientRegister}
* 세션 마커로 통제한다. 마커는 홈 최초 진입에서 1회 소비하여, 이후 홈 재방문 시
* 반복 노출되지 않게 한다. (역할 게이팅은 마커 세팅 측에서 이미 수행됨)</p>
*/
private void resolveClientRegisterNudge(Model model, HttpSession session) {
if (session.getAttribute("checkClientRegister") == null) {
return;
}
session.removeAttribute("checkClientRegister"); // 1회 소비
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
if (user == null || user.getPortalOrg() == null) {
return;
}
boolean hasClient = !appServiceFacade.getApikeyList(user.getPortalOrg()).isEmpty()
|| !appServiceFacade.getPendingApiKeyList(user.getPortalOrg()).isEmpty();
if (!hasClient) {
model.addAttribute("needClientRegister", true);
}
}
private void addAttributesToModel(Model model, List<ApiServiceDTO> apiServices, List<String> hashTags, IndexStatisticsDTO statistics) { private void addAttributesToModel(Model model, List<ApiServiceDTO> apiServices, List<String> hashTags, IndexStatisticsDTO statistics) {
model.addAttribute("services", apiServices); model.addAttribute("services", apiServices);
@@ -1,6 +1,5 @@
package com.eactive.apim.portal.apps.session.controller; package com.eactive.apim.portal.apps.session.controller;
import com.eactive.apim.portal.apps.session.entity.UserSession;
import com.eactive.apim.portal.apps.session.service.UserSessionService; import com.eactive.apim.portal.apps.session.service.UserSessionService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -9,23 +8,23 @@ import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession; import javax.servlet.http.HttpSession;
import java.time.format.DateTimeFormatter;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.Optional;
/** /**
* 세션 타이머/유휴 로그아웃/중복로그인 처리용 REST API. * 세션 타이머/유휴 로그아웃 처리용 REST API.
*
* <p>중복 세션 확인은 비밀번호 검증 전 정보 노출 문제로 로그인 전 사전 체크
* ({@code /api/session/check-duplicate})를 제거하고, 1차 인증 통과 후
* {@code DuplicateLoginService} 가 처리한다.</p>
* *
* <ul> * <ul>
* <li>GET /api/session/status - 잔여 시간/유효성 폴링 (인증 필요)</li> * <li>GET /api/session/status - 잔여 시간/유효성 폴링 (인증 필요)</li>
* <li>POST /api/session/heartbeat - 세션 연장 (lastAccessTime 갱신)</li> * <li>POST /api/session/heartbeat - 세션 연장 (lastAccessTime 갱신)</li>
* <li>POST /api/session/check-duplicate - 로그인 전 중복 세션 확인 (CSRF 예외)</li>
* <li>GET /api/session/ping - 익명 세션 keepalive (로그인/회원가입 페이지)</li> * <li>GET /api/session/ping - 익명 세션 keepalive (로그인/회원가입 페이지)</li>
* <li>GET /api/session/csrf - 현재 CSRF 토큰 조회 (로그인 제출 직전 안전망)</li> * <li>GET /api/session/csrf - 현재 CSRF 토큰 조회 (로그인 제출 직전 안전망)</li>
* </ul> * </ul>
@@ -36,32 +35,8 @@ import java.util.Optional;
@RequiredArgsConstructor @RequiredArgsConstructor
public class SessionApiController { public class SessionApiController {
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final UserSessionService userSessionService; private final UserSessionService userSessionService;
/**
* 로그인 전 중복 세션 확인
*/
@PostMapping("/check-duplicate")
public ResponseEntity<Map<String, Object>> checkDuplicate(@RequestParam("loginId") String loginId) {
Map<String, Object> result = new HashMap<>();
String normalizedLoginId = loginId != null ? loginId.toLowerCase() : "";
Optional<UserSession> activeSession = userSessionService.getActiveSession(normalizedLoginId);
if (activeSession.isPresent()) {
UserSession session = activeSession.get();
result.put("duplicateSession", true);
result.put("ipAddress", maskIpAddress(session.getIpAddress()));
result.put("loginTime", session.getLoginTime().format(TIME_FORMATTER));
} else {
result.put("duplicateSession", false);
}
return ResponseEntity.ok(result);
}
/** /**
* 세션 상태 폴링 (인증 필요) * 세션 상태 폴링 (인증 필요)
*/ */
@@ -142,23 +117,4 @@ public class SessionApiController {
} }
return ResponseEntity.ok(result); return ResponseEntity.ok(result);
} }
/**
* IP 주소 마스킹 (3번째 옥텟을 ***로 치환)
* 예: 192.168.240.178 → 192.168.***.178
*/
private String maskIpAddress(String ip) {
if (ip == null || ip.isEmpty()) {
return "알 수 없음";
}
String[] parts = ip.split("\\.");
if (parts.length == 4) {
return parts[0] + "." + parts[1] + ".***." + parts[3];
}
// IPv6 등 다른 형식은 일부만 표시
if (ip.length() > 8) {
return ip.substring(0, 4) + "****" + ip.substring(ip.length() - 4);
}
return "***";
}
} }
@@ -57,6 +57,13 @@ public class AccountController {
private final TwoFactorProperties twoFactorProperties; private final TwoFactorProperties twoFactorProperties;
/** 비밀번호 변경 화면 라이브 체크: 입력 중인 비밀번호에 아이디/휴대전화가 포함되는지 (민감정보는 응답에 미포함) */
@PostMapping("/password/content-check")
public ResponseEntity<Map<String, Boolean>> checkPasswordContent(@RequestParam String password) {
String currentLoginId = SecurityUtil.getCurrentLoginId();
return ResponseEntity.ok(userFacade.checkPasswordContent(currentLoginId, password));
}
@PostMapping("/password/confirm") @PostMapping("/password/confirm")
public ResponseEntity<ValidationResponse> confirmPassword(@RequestParam String inputPassword) { public ResponseEntity<ValidationResponse> confirmPassword(@RequestParam String inputPassword) {
String currentLoginId = SecurityUtil.getCurrentLoginId(); String currentLoginId = SecurityUtil.getCurrentLoginId();
@@ -136,7 +143,8 @@ public class AccountController {
new SecurityContextLogoutHandler().logout(request, response, new SecurityContextLogoutHandler().logout(request, response,
SecurityContextHolder.getContext().getAuthentication()); SecurityContextHolder.getContext().getAuthentication());
redirectAttributes.addFlashAttribute("success", "비밀번호가 성공적으로 변경되었습니다."); redirectAttributes.addFlashAttribute("success",
"비밀번호가 성공적으로 변경되었습니다.<br>새 비밀번호로 다시 로그인해주세요.");
return "redirect:/login"; return "redirect:/login";
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
// 검증 실패(비밀번호 규칙/이력 등) — 사용자에게 안내, 스택은 불필요 // 검증 실패(비밀번호 규칙/이력 등) — 사용자에게 안내, 스택은 불필요
@@ -182,6 +190,9 @@ public class AccountController {
// 기존 user 객체도 유지 (다른 곳에서 필요할 수 있으므로) // 기존 user 객체도 유지 (다른 곳에서 필요할 수 있으므로)
mav.addObject("user", user); mav.addObject("user", user);
// 회원 탈퇴 팝업: 제출 전에 2FA 팝업을 띄울지 여부
mav.addObject("withdrawTwofaRequired", isWithdrawTwofaRequired());
// ROLE_USER인 경우 초대 여부 확인 // ROLE_USER인 경우 초대 여부 확인
if (currentUser.getRoleCode() == RoleCode.ROLE_USER) { if (currentUser.getRoleCode() == RoleCode.ROLE_USER) {
java.util.Optional<UserInvitation> pendingInvitation = java.util.Optional<UserInvitation> pendingInvitation =
@@ -401,21 +412,35 @@ public class AccountController {
@PostMapping("/withdraw") @PostMapping("/withdraw")
public String processWithdrawal( public String processWithdrawal(
@RequestParam(value = "withdrawReason", required = false) String withdrawReason,
HttpSession session, HttpSession session,
RedirectAttributes redirectAttributes) { RedirectAttributes redirectAttributes) {
try { try {
// 탈퇴 사유 필수
if (withdrawReason == null || withdrawReason.trim().isEmpty()) {
redirectAttributes.addFlashAttribute("error", "탈퇴 사유를 입력해 주세요.");
return "redirect:/mypage";
}
// 반영 직전 2FA: 통과권이 없으면 커밋하지 않는다(프론트가 먼저 2FA 팝업을 띄운다).
if (isWithdrawTwofaRequired()
&& !twoFactorService.consumeStepUpPass(session, StepUpProtectedPaths.WITHDRAW)) {
redirectAttributes.addFlashAttribute("error", "추가 인증(2FA) 후 다시 시도해 주세요.");
return "redirect:/mypage";
}
// 현재 로그인한 사용자 정보 가져오기 // 현재 로그인한 사용자 정보 가져오기
PortalAuthenticatedUser currentUser = SecurityUtil.getPortalAuthenticatedUser(); PortalAuthenticatedUser currentUser = SecurityUtil.getPortalAuthenticatedUser();
// 회원 탈퇴 처리 // 회원 탈퇴 처리
if (currentUser != null) { if (currentUser != null) {
userFacade.withdrawUser(currentUser.getId()); userFacade.withdrawUser(currentUser.getId(), withdrawReason.trim());
} }
session.invalidate(); session.invalidate();
SecurityContextHolder.clearContext(); SecurityContextHolder.clearContext();
redirectAttributes.addFlashAttribute("success", "회원 탈퇴 신청이 완료 되었습니다. API Portal 회원 정보가 완전히 삭제 니다."); redirectAttributes.addFlashAttribute("success", "회원 탈퇴 완료 되었습니다. API Portal 회원 정보가 완전히 삭제 되었습니다.");
return "redirect:/"; return "redirect:/";
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
redirectAttributes.addFlashAttribute("error", e.getMessage()); redirectAttributes.addFlashAttribute("error", e.getMessage());
@@ -423,6 +448,12 @@ public class AccountController {
} }
} }
/** 회원 탈퇴({@code /withdraw}) 반영 직전 2FA 를 요구할지 여부 */
private boolean isWithdrawTwofaRequired() {
return twoFactorProperties.isStepUpEnabled()
&& twoFactorProperties.isStepUpPointEnabled(StepUpProtectedPaths.WITHDRAW);
}
@GetMapping("/mypage/verification-email") @GetMapping("/mypage/verification-email")
public String showVerificationEmailPage(Model model) { public String showVerificationEmailPage(Model model) {
try { try {
@@ -54,6 +54,7 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
private final FileService fileService; private final FileService fileService;
private final BasicValidationService validationService; private final BasicValidationService validationService;
private final UserRegistrationValidationService userRegistrationValidationService; private final UserRegistrationValidationService userRegistrationValidationService;
private final com.eactive.apim.portal.apps.user.validator.PasswordValidator passwordValidator;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final AgreementValidator agreementValidator; private final AgreementValidator agreementValidator;
private final ApprovalService approvalService; private final ApprovalService approvalService;
@@ -75,14 +76,29 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
return new ValidationResponse(false, "입력 정보가 올바르지 않습니다."); return new ValidationResponse(false, "입력 정보가 올바르지 않습니다.");
} }
// 2025.10.20 - 휴대폰 번호 중복 무시 // 개인 가입(@Valid @PasswordRule)과 달리 법인 가입은 컨트롤러 바인딩 검증이 없어
// PortalUser existingUser = portalUserRepository.findByUserNameAndMobileNumber(orgDTO.getUserName(), orgDTO.getMobileNumber()); // 여기서 서버 측 비밀번호 규칙을 직접 검증한다 (retain/change 시나리오는 기존 비밀번호 유지라 제외)
if (!passwordValidator.isValidPassword(orgDTO.getPassword(), orgDTO.getLoginId(), orgDTO.getMobileNumber())) {
return new ValidationResponse(false,
"비밀번호는 영문/숫자/특수문자 포함 8~50자이며, 아이디·휴대전화 번호, 3자리 이상 연속·반복 문자는 사용할 수 없습니다.");
}
if (orgDTO.getConfirmPassword() == null || !orgDTO.getConfirmPassword().equals(orgDTO.getPassword())) {
return new ValidationResponse(false, "비밀번호와 비밀번호 확인이 일치하지 않습니다.");
}
Optional<PortalUser> existingUser = portalUserRepository.findByLoginId(orgDTO.getLoginId()); Optional<PortalUser> existingUser = portalUserRepository.findByLoginId(orgDTO.getLoginId());
if(existingUser.isPresent()) { if(existingUser.isPresent()) {
return new ValidationResponse(false, "가입된 계정이 이미 존재합니다."); return new ValidationResponse(false, "가입된 계정이 이미 존재합니다.");
} }
// 휴대폰 번호 중복 검증 (Portal/user.mobile.duplicate.allow 프로퍼티에 따라 차단)
if (portalUserService.isMobileDuplicateCheckEnabled()
&& portalUserService.existsByMobileNumber(orgDTO.getMobileNumber())) {
return new ValidationResponse(false, "이미 가입된 휴대폰 번호입니다.");
}
try { try {
FileInfo uploadedFile = handleFileUpload(orgDTO.getFiles()); FileInfo uploadedFile = handleFileUpload(orgDTO.getFiles());
if (uploadedFile == null) { if (uploadedFile == null) {
@@ -10,11 +10,14 @@ public interface UserFacade {
void updatePassword(String loginId, String newPassword, String confirmPassword); void updatePassword(String loginId, String newPassword, String confirmPassword);
/** 비밀번호에 아이디/휴대전화 번호가 포함되는지 라이브 체크용 판정 (키: idIncluded, mobileIncluded) */
java.util.Map<String, Boolean> checkPasswordContent(String loginId, String password);
void updateUser(PortalUserDTO portalUserDTO); void updateUser(PortalUserDTO portalUserDTO);
void updateCorporateManager(PortalUserDTO portalUserDTO); void updateCorporateManager(PortalUserDTO portalUserDTO);
void withdrawUser(String userId); void withdrawUser(String userId, String withdrawalReason);
void activateUserByEmail(String email); void activateUserByEmail(String email);
} }
@@ -60,6 +60,12 @@ public class UserFacadeImpl implements UserFacade {
messageHandlerService.publishEvent(UserPasswordChangedEvent.KEY, recipient, params); messageHandlerService.publishEvent(UserPasswordChangedEvent.KEY, recipient, params);
} }
@Override
@Transactional(readOnly = true)
public HashMap<String, Boolean> checkPasswordContent(String loginId, String password) {
return passwordService.checkPasswordContent(loginId, password);
}
@Override @Override
@Transactional @Transactional
public void updateUser(PortalUserDTO portalUserDTO) { public void updateUser(PortalUserDTO portalUserDTO) {
@@ -131,7 +137,7 @@ public class UserFacadeImpl implements UserFacade {
} }
@Override @Override
public void withdrawUser(String userId) { public void withdrawUser(String userId, String withdrawalReason) {
PortalUser user = portalUserService.findById(userId); PortalUser user = portalUserService.findById(userId);
// 법인 관리자 탈퇴 제한 // 법인 관리자 탈퇴 제한
@@ -147,7 +153,7 @@ public class UserFacadeImpl implements UserFacade {
// 메시지 요청정보 삭제 // 메시지 요청정보 삭제
messageRequestFacade.deleteUserMessage(user.getUserName(),user.getLoginId()); messageRequestFacade.deleteUserMessage(user.getUserName(),user.getLoginId());
portalUserService.deleteUser(user); portalUserService.deleteUser(user, withdrawalReason);
log.info("회원 탈퇴 처리 완료: {}", StringMaskingUtil.maskLoginId(user.getLoginId())); log.info("회원 탈퇴 처리 완료: {}", StringMaskingUtil.maskLoginId(user.getLoginId()));
} }
@@ -187,7 +187,8 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
return new ValidationResponse(false, "이미 가입된 휴대폰 번호입니다."); return new ValidationResponse(false, "이미 가입된 휴대폰 번호입니다.");
} }
PortalUser existingUser = portalUserRepository.findByUserNameAndMobileNumber(registrationDTO.getUserName(), registrationDTO.getMobileNumber()); PortalUser existingUser = portalUserRepository.findByUserNameAndMobileNumber(registrationDTO.getUserName(),
com.eactive.apim.portal.common.util.PhoneNumberUtil.normalize(registrationDTO.getMobileNumber()));
if(existingUser != null) { if(existingUser != null) {
return new ValidationResponse(false, "가입된 계정이 이미 존재합니다."); return new ValidationResponse(false, "가입된 계정이 이미 존재합니다.");
@@ -1,6 +1,7 @@
package com.eactive.apim.portal.apps.user.service; package com.eactive.apim.portal.apps.user.service;
import com.eactive.apim.portal.common.dto.PasswordValidationDTO; import com.eactive.apim.portal.common.dto.PasswordValidationDTO;
import com.eactive.apim.portal.common.validator.PasswordRuleValidator;
import com.eactive.apim.portal.portaluser.entity.PortalUser; import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.UserPasswordHistory; import com.eactive.apim.portal.portaluser.entity.UserPasswordHistory;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository; import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
@@ -71,6 +72,19 @@ public class PasswordService {
} }
} }
/** 비밀번호에 본인 아이디(local part)/휴대전화 세그먼트가 포함되는지 판정 — 변경 화면 라이브 체크용 */
@Transactional(readOnly = true)
public java.util.HashMap<String, Boolean> checkPasswordContent(String loginId, String password) {
PortalUser user = portalUserRepository.findByLoginId(loginId)
.orElseThrow(() -> new IllegalArgumentException("해당 사용자를 찾을 수 없습니다."));
java.util.HashMap<String, Boolean> result = new java.util.HashMap<>();
result.put("idIncluded",
PasswordRuleValidator.containsLoginIdLocalPart(password, user.getLoginId()));
result.put("mobileIncluded",
PasswordRuleValidator.containsMobileSegment(password, user.getMobileNumber()));
return result;
}
private void checkPasswordHistory(String userId, String newPassword) { private void checkPasswordHistory(String userId, String newPassword) {
List<UserPasswordHistory> passwordHistories = passwordHistoryRepository.findRecentPasswordsByUserId(userId); List<UserPasswordHistory> passwordHistories = passwordHistoryRepository.findRecentPasswordsByUserId(userId);
@@ -5,11 +5,11 @@ import com.eactive.apim.portal.apps.login.service.LoginFinalizer;
import com.eactive.apim.portal.apps.user.dto.PortalUserDTO; import com.eactive.apim.portal.apps.user.dto.PortalUserDTO;
import com.eactive.apim.portal.apps.user.mapper.PortalUserMapper; import com.eactive.apim.portal.apps.user.mapper.PortalUserMapper;
import com.eactive.apim.portal.common.exception.SystemException; import com.eactive.apim.portal.common.exception.SystemException;
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
import com.eactive.apim.portal.common.exception.UserNotFoundException; import com.eactive.apim.portal.common.exception.UserNotFoundException;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser; import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.EncryptionUtil; import com.eactive.apim.portal.common.util.EncryptionUtil;
import com.eactive.apim.portal.common.util.SecurityUtil; import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.util.StringMaskingUtil;
import com.eactive.apim.portal.config.PortalProperties; import com.eactive.apim.portal.config.PortalProperties;
import com.eactive.apim.portal.portaluser.entity.PortalUser; import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums; import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
@@ -136,12 +136,9 @@ public class PortalUserAuthService implements UserDetailsService {
throw new UserNotFoundException("입력하신 사용자 정보가 올바르지 않습니다. 다시 확인해 주세요."); throw new UserNotFoundException("입력하신 사용자 정보가 올바르지 않습니다. 다시 확인해 주세요.");
} }
return users.stream().map(user -> { // 아이디 찾기: 이름+휴대폰 본인인증을 마친 사용자에게 보여주는 결과이므로
PortalUserDTO dto = portalUserMapper.toDTO(user); // 이메일(아이디)을 마스킹 없이 그대로 반환한다.
dto.setMaskedEmailAddr(StringMaskingUtil.maskEmail(dto.getLoginId())); return users.stream().map(portalUserMapper::toDTO).collect(Collectors.toList());
dto.setLoginId(null);
return dto;
}).collect(Collectors.toList());
} catch (UserNotFoundException e) { } catch (UserNotFoundException e) {
throw e; throw e;
@@ -155,6 +152,8 @@ public class PortalUserAuthService implements UserDetailsService {
} }
public void resetPassword(String loginId, String userName, String mobileNumber) { public void resetPassword(String loginId, String userName, String mobileNumber) {
// 입력 그룹핑이 저장 정규형과 달라도 매칭되도록 조회 전 정규화 (암호화 컬럼 등가 비교)
mobileNumber = PhoneNumberUtil.normalize(mobileNumber);
if (mobileNumber == null || !mobileNumber.matches("^\\d{2,3}-\\d{3,4}-\\d{4}$")) { if (mobileNumber == null || !mobileNumber.matches("^\\d{2,3}-\\d{3,4}-\\d{4}$")) {
throw new UserNotFoundException("유효하지 않은 휴대폰 번호 형식입니다."); throw new UserNotFoundException("유효하지 않은 휴대폰 번호 형식입니다.");
} }
@@ -164,6 +163,9 @@ public class PortalUserAuthService implements UserDetailsService {
String tempPassword = EncryptionUtil.generateNewPassword(); String tempPassword = EncryptionUtil.generateNewPassword();
portalUser.setPasswordHash(passwordEncoder.encode(tempPassword)); portalUser.setPasswordHash(passwordEncoder.encode(tempPassword));
// 임시 비밀번호 발급 → 변경일을 null 로 초기화해 로그인 시 강제 비밀번호 변경을 유도한다
// (LoginFinalizer.applyPostLoginState 의 passwordChangeDate == null 분기)
portalUser.setPasswordChangeDate(null);
if ("Y".equalsIgnoreCase(portalUser.getAccountLockYn())) { if ("Y".equalsIgnoreCase(portalUser.getAccountLockYn())) {
portalUser.setAccountLockYn("N"); portalUser.setAccountLockYn("N");
} }
@@ -186,7 +188,7 @@ public class PortalUserAuthService implements UserDetailsService {
@Transactional @Transactional
public void reactivateDormantAccount(String loginId, String password, String mobileNumber) { public void reactivateDormantAccount(String loginId, String password, String mobileNumber) {
try { try {
PortalUser portalUser = portalUserRepository.findByLoginIdAndMobileNumber(loginId, mobileNumber) PortalUser portalUser = portalUserRepository.findByLoginIdAndMobileNumber(loginId, PhoneNumberUtil.normalize(mobileNumber))
.orElseThrow(() -> new UserNotFoundException("입력하신 사용자 정보가 올바르지 않습니다. 다시 확인해 주세요.")); .orElseThrow(() -> new UserNotFoundException("입력하신 사용자 정보가 올바르지 않습니다. 다시 확인해 주세요."));
if (!passwordEncoder.matches(password, portalUser.getPasswordHash())) { if (!passwordEncoder.matches(password, portalUser.getPasswordHash())) {
@@ -280,7 +280,7 @@ public class PortalUserService {
return portalUserRepository.save(user); return portalUserRepository.save(user);
} }
public void deleteUser(PortalUser user) { public void deleteUser(PortalUser user, String withdrawalReason) {
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
String withdrawalDate = now.format(DateTimeFormatter.ofPattern("yyyyMMddHHmm")); String withdrawalDate = now.format(DateTimeFormatter.ofPattern("yyyyMMddHHmm"));
@@ -290,6 +290,12 @@ public class PortalUserService {
user.setMobileNumber(""); user.setMobileNumber("");
user.setPasswordHash(""); user.setPasswordHash("");
// 탈퇴 사유 보존 (컬럼 길이 200 초과분은 잘라 저장)
if (withdrawalReason != null && withdrawalReason.length() > 200) {
withdrawalReason = withdrawalReason.substring(0, 200);
}
user.setWithdrawalReason(withdrawalReason);
user.setUserStatus(PortalUserEnums.UserStatus.REMOVED); user.setUserStatus(PortalUserEnums.UserStatus.REMOVED);
portalUserRepository.save(user); portalUserRepository.save(user);
@@ -0,0 +1,44 @@
package com.eactive.apim.portal.common.security;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/**
* 로그인 실패 계정 잠금 임계 횟수를 DB(PortalProperty)에서 조회한다.
*
* <p>PTL_PROPERTY (group={@code Portal}, name={@code login.failure.lock.count}) 값으로 제어한다.
* 값이 없으면 기본값 {@value #DEFAULT_LOCK_COUNT}로 자동 생성되고, 숫자가 아니거나
* 0 이하이면 기본값으로 동작한다.</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class LoginLockPolicy {
private static final String GROUP = "Portal";
private static final String NAME = "login.failure.lock.count";
/** 기본 잠금 임계 횟수 (프로퍼티 미존재/파싱 실패 시) */
public static final int DEFAULT_LOCK_COUNT = 5;
private final PortalPropertyService portalPropertyService;
/** 연속 로그인 실패가 이 값 이상이면 계정을 잠근다. */
public int lockCount() {
String raw = portalPropertyService.getOrCreateProperty(
GROUP, NAME, String.valueOf(DEFAULT_LOCK_COUNT),
"로그인 연속 실패 계정 잠금 임계 횟수 (이 값 이상 실패 시 잠금)");
try {
int parsed = Integer.parseInt(raw.trim());
if (parsed > 0) {
return parsed;
}
log.warn("login.failure.lock.count 값이 0 이하({}) - 기본값 {} 사용", parsed, DEFAULT_LOCK_COUNT);
} catch (NumberFormatException e) {
log.warn("login.failure.lock.count 값이 숫자가 아님('{}') - 기본값 {} 사용", raw, DEFAULT_LOCK_COUNT);
}
return DEFAULT_LOCK_COUNT;
}
}
@@ -172,6 +172,44 @@ public class StringMaskingUtil {
return value; return value;
} }
// 폼(application/x-www-form-urlencoded) 본문에서 값을 리댁트할 파라미터 키(소문자 완전일치)
private static final java.util.Set<String> SENSITIVE_FORM_PARAMS = new java.util.HashSet<>(Arrays.asList(
"client_secret", "clientsecret", "secret", "password", "passwd", "pwd",
"refresh_token", "access_token", "id_token", "code", "assertion"));
/**
* {@code application/x-www-form-urlencoded} 본문 로깅 시 민감 파라미터(client_secret/password 등)의
* 값을 {@link #maskToken(String)} 규칙(앞4·뒤4)으로 마스킹한다. client_id·grant_type·scope 등은
* "client not found" 류 원인 파악을 위해 원본 그대로 남긴다. 폼 형식이 아니면 원본을 반환한다(멱등).
* <pre>grant_type=client_credentials&client_id=ABC&client_secret=s3cr3tValue → …&client_secret=s3cr***alue</pre>
*/
public static String maskFormBody(String body) {
if (!isValidString(body) || body.indexOf('=') < 0) {
return body;
}
String[] pairs = body.split("&");
StringBuilder sb = new StringBuilder(body.length());
for (int i = 0; i < pairs.length; i++) {
if (i > 0) {
sb.append('&');
}
String pair = pairs[i];
int eq = pair.indexOf('=');
if (eq < 0) {
sb.append(pair);
continue;
}
String key = pair.substring(0, eq);
String value = pair.substring(eq + 1);
if (SENSITIVE_FORM_PARAMS.contains(key.toLowerCase())) {
sb.append(key).append('=').append(maskToken(value));
} else {
sb.append(pair);
}
}
return sb.toString();
}
// 기존 메서드 오버로딩 (하위 호환성) // 기존 메서드 오버로딩 (하위 호환성)
public static String maskName(String name) { public static String maskName(String name) {
return maskName(name, null, null); return maskName(name, null, null);
@@ -1,5 +1,6 @@
package com.eactive.apim.portal.common.validator; package com.eactive.apim.portal.common.validator;
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
import org.apache.commons.beanutils.PropertyUtils; import org.apache.commons.beanutils.PropertyUtils;
import javax.validation.ConstraintValidator; import javax.validation.ConstraintValidator;
@@ -72,25 +73,13 @@ public class PasswordRuleValidator implements ConstraintValidator<PasswordRule,
return false; return false;
} }
if (loginId != null && !loginId.isEmpty()) { if (containsLoginIdLocalPart(password, loginId)) {
String[] loginParts = loginId.split("@");
if (loginParts.length > 0) {
String username = loginParts[0].toUpperCase();
if (tmpPw.contains(username)) {
return false; return false;
} }
}
}
// Mobile number validation if (containsMobileSegment(password, mobileNumber)) {
if (mobileNumber != null && !mobileNumber.isEmpty()) {
String[] mobileParts = mobileNumber.split("-");
for (String part : mobileParts) {
if (!part.isEmpty() && tmpPw.contains(part)) {
return false; return false;
} }
}
}
// 공백 체크 // 공백 체크
matcher = Pattern.compile(BLANKPT).matcher(tmpPw); matcher = Pattern.compile(BLANKPT).matcher(tmpPw);
@@ -129,6 +118,33 @@ public class PasswordRuleValidator implements ConstraintValidator<PasswordRule,
return true; return true;
} }
/** 아이디(이메일)의 @ 앞 local part 가 비밀번호에 포함되는지 (대소문자 무시) */
public static boolean containsLoginIdLocalPart(String password, String loginId) {
if (password == null || loginId == null || loginId.isEmpty()) {
return false;
}
String username = loginId.split("@")[0].toUpperCase();
return !username.isEmpty() && password.toUpperCase().contains(username);
}
/**
* 휴대전화 번호의 하이픈 세그먼트(010/1234/5678)가 비밀번호에 포함되는지.
* DB 에 하이픈 없이 저장된 legacy 값도 잡도록 정규형으로 변환 후 분리한다.
*/
public static boolean containsMobileSegment(String password, String mobileNumber) {
if (password == null || mobileNumber == null || mobileNumber.isEmpty()) {
return false;
}
String tmpPw = password.toUpperCase();
String[] mobileParts = PhoneNumberUtil.normalize(mobileNumber).split("-");
for (String part : mobileParts) {
if (!part.isEmpty() && tmpPw.contains(part)) {
return true;
}
}
return false;
}
static boolean isContinuous(int first, int third) { static boolean isContinuous(int first, int third) {
// 첫 글자 A-Z / 0-9 // 첫 글자 A-Z / 0-9
return (first > 47 && third < 58) || (first > 64 && third < 91); return (first > 47 && third < 58) || (first > 64 && third < 91);
@@ -4,6 +4,7 @@ import com.eactive.apim.portal.apps.login.constants.LoginConstants;
import com.eactive.apim.portal.apps.login.constants.LoginFailureReason; import com.eactive.apim.portal.apps.login.constants.LoginFailureReason;
import com.eactive.apim.portal.apps.user.service.PortalUserLogService; import com.eactive.apim.portal.apps.user.service.PortalUserLogService;
import com.eactive.apim.portal.common.exception.UserNotFoundException; import com.eactive.apim.portal.common.exception.UserNotFoundException;
import com.eactive.apim.portal.common.security.LoginLockPolicy;
import com.eactive.apim.portal.common.util.HttpRequestUtil; import com.eactive.apim.portal.common.util.HttpRequestUtil;
import com.eactive.apim.portal.common.util.StringMaskingUtil; import com.eactive.apim.portal.common.util.StringMaskingUtil;
import com.eactive.apim.portal.common.util.StringRepeatUtil; import com.eactive.apim.portal.common.util.StringRepeatUtil;
@@ -47,14 +48,17 @@ public class PortalAuthenticationFailureHandler implements AuthenticationFailure
private final PortalUserRepository portalUserRepository; private final PortalUserRepository portalUserRepository;
private final PortalUserLogService userLogService; private final PortalUserLogService userLogService;
private final MessageHandlerService messageHandlerService; private final MessageHandlerService messageHandlerService;
private final LoginLockPolicy loginLockPolicy;
public PortalAuthenticationFailureHandler(PortalUserRepository portalUserRepository, public PortalAuthenticationFailureHandler(PortalUserRepository portalUserRepository,
PortalUserLogService userLogService, PortalUserLogService userLogService,
MessageHandlerService messageHandlerService) { MessageHandlerService messageHandlerService,
LoginLockPolicy loginLockPolicy) {
this.portalUserRepository = portalUserRepository; this.portalUserRepository = portalUserRepository;
this.userLogService = userLogService; this.userLogService = userLogService;
this.messageHandlerService = messageHandlerService; this.messageHandlerService = messageHandlerService;
this.loginLockPolicy = loginLockPolicy;
} }
@Override @Override
@@ -73,15 +77,16 @@ public class PortalAuthenticationFailureHandler implements AuthenticationFailure
PortalUser user = portalUserRepository.findPortalUserByEmailAddr(normalizedUsername) PortalUser user = portalUserRepository.findPortalUserByEmailAddr(normalizedUsername)
.orElseThrow(() -> new UserNotFoundException(normalizedUsername)); .orElseThrow(() -> new UserNotFoundException(normalizedUsername));
int lockCount = loginLockPolicy.lockCount();
user.setLoginFailureCount(user.getLoginFailureCount() + 1); user.setLoginFailureCount(user.getLoginFailureCount() + 1);
if (user.getLoginFailureCount() >= 5) { if (user.getLoginFailureCount() >= lockCount) {
user.setAccountLockYn("Y"); user.setAccountLockYn("Y");
// 계정 잠금 알림 // 계정 잠금 알림
messageHandlerService.publishEvent( messageHandlerService.publishEvent(
MessageCode.USER_ACCOUNT_LOCKED, MessageCode.USER_ACCOUNT_LOCKED,
MessageRecipient.of(user), MessageRecipient.of(user),
Maps.of("reason", "5회 이상 로그인 실패로 인한 계정 잠금")) ;; Maps.of("reason", lockCount + "회 이상 로그인 실패로 인한 계정 잠금"));
} }
portalUserRepository.save(user); portalUserRepository.save(user);
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.config;
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService; import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
import com.eactive.apim.portal.common.security.LoginLockPolicy;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser; import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums; import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums; import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
@@ -28,6 +29,7 @@ public class PortalAuthenticationManager implements AuthenticationManager {
private final PortalUserAuthService portalUserAuthService; private final PortalUserAuthService portalUserAuthService;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final MessageHandlerService messageHandlerService; private final MessageHandlerService messageHandlerService;
private final LoginLockPolicy loginLockPolicy;
@Override @Override
@Transactional(noRollbackFor = {AuthenticationException.class}) @Transactional(noRollbackFor = {AuthenticationException.class})
@@ -40,7 +42,7 @@ public class PortalAuthenticationManager implements AuthenticationManager {
if (!user.isAccountNonLocked()) { if (!user.isAccountNonLocked()) {
if (user.getLoginFailureCount() >= 5) { if (user.getLoginFailureCount() >= loginLockPolicy.lockCount()) {
throw new LockedException("계정이 잠겼습니다. 비밀번호 초기화 또는 관리자에게 문의하세요."); throw new LockedException("계정이 잠겼습니다. 비밀번호 초기화 또는 관리자에게 문의하세요.");
} }
@@ -3,6 +3,7 @@ package com.eactive.apim.portal.config;
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties; import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties;
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService; import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService;
import com.eactive.apim.portal.apps.login.constants.LoginType; import com.eactive.apim.portal.apps.login.constants.LoginType;
import com.eactive.apim.portal.apps.login.service.DuplicateLoginService;
import com.eactive.apim.portal.apps.login.service.LoginFinalizer; import com.eactive.apim.portal.apps.login.service.LoginFinalizer;
import com.eactive.apim.portal.portaluser.entity.PortalUser; import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums; import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
@@ -29,7 +30,9 @@ import java.io.IOException;
* 비워 사용자를 익명으로 되돌린 뒤 {@code /login?twofactor=1} 로 보낸다. 로그인 페이지가 * 비워 사용자를 익명으로 되돌린 뒤 {@code /login?twofactor=1} 로 보낸다. 로그인 페이지가
* 공통 2FA 팝업을 자동 오픈하고, 인증 성공 시 {@code TwoFactorService} 가 최종 확정한다.</p> * 공통 2FA 팝업을 자동 오픈하고, 인증 성공 시 {@code TwoFactorService} 가 최종 확정한다.</p>
* *
* <p>2FA off(또는 DORMANT)면 {@link LoginFinalizer} 로 기존과 동일하게 즉시 확정한다. * <p>2FA off 면 동시 접속(다른 곳 활성 세션) 여부를 확인해, 있으면 확정을 보류하고
* {@code /login?duplicate=1} 확인 팝업으로 유도한다({@link DuplicateLoginService}).
* 없으면(또는 DORMANT) {@link LoginFinalizer} 로 기존과 동일하게 즉시 확정한다.
* 실질 후처리 로직은 모두 {@link LoginFinalizer} 로 이관되어 로그인/2FA/가입자동로그인이 공유한다.</p> * 실질 후처리 로직은 모두 {@link LoginFinalizer} 로 이관되어 로그인/2FA/가입자동로그인이 공유한다.</p>
*/ */
@Service @Service
@@ -41,6 +44,7 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
private final LoginFinalizer loginFinalizer; private final LoginFinalizer loginFinalizer;
private final TwoFactorService twoFactorService; private final TwoFactorService twoFactorService;
private final TwoFactorProperties twoFactorProperties; private final TwoFactorProperties twoFactorProperties;
private final DuplicateLoginService duplicateLoginService;
@Override @Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
@@ -56,7 +60,9 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
boolean dormant = PortalUserEnums.UserStatus.DORMANT.equals(user.getUserStatus()); boolean dormant = PortalUserEnums.UserStatus.DORMANT.equals(user.getUserStatus());
// 로그인 2FA: ID/PW 는 맞았으므로 실패카운트만 리셋하고, 최종 확정은 2FA 성공까지 보류한다. // 로그인 2FA: ID/PW 는 맞았으므로 실패카운트만 리셋하고, 최종 확정은 2FA 성공까지 보류한다.
if (twoFactorProperties.isLoginEnabled() && !dormant) { // 대상 역할(two-factor.login.target-roles, 기본 법인관리자만)에 한해 적용한다.
if (twoFactorProperties.isLoginEnabled() && !dormant
&& twoFactorProperties.isLoginTargetRole(user.getRoleCode())) {
user.setLoginFailureCount(0); user.setLoginFailureCount(0);
portalUserRepository.save(user); portalUserRepository.save(user);
@@ -71,6 +77,23 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
return; return;
} }
// 2FA off: 동시 접속(다른 곳 활성 세션)이 있으면 확정을 보류하고 확인 팝업으로 유도한다.
// 중복 확인은 비밀번호 검증 통과 후에만 노출한다(사전 체크는 접속 여부/IP 정보 노출).
if (!dormant && duplicateLoginService.hasActiveSession(normalizedUsername)) {
user.setLoginFailureCount(0);
portalUserRepository.save(user);
HttpSession session = request.getSession();
duplicateLoginService.begin(session, user);
// 확인 완료 전까지 익명 상태로 되돌린다(보호 경로 자동 차단, LoginHandler 튕김 회피).
SecurityContextHolder.clearContext();
session.removeAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY);
response.sendRedirect(request.getContextPath() + "/login?duplicate=1");
return;
}
// 2FA off (또는 DORMANT) → 기존과 동일하게 즉시 확정 // 2FA off (또는 DORMANT) → 기존과 동일하게 즉시 확정
String redirect = loginFinalizer.finalizeLogin(user, username, request, LoginType.NORMAL); String redirect = loginFinalizer.finalizeLogin(user, username, request, LoginType.NORMAL);
response.sendRedirect(redirect); response.sendRedirect(redirect);
@@ -108,7 +108,6 @@ public class PortalConfigSecurity {
.csrf(csrf -> csrf .csrf(csrf -> csrf
.csrfTokenRepository(csrfTokenRepository) .csrfTokenRepository(csrfTokenRepository)
.ignoringRequestMatchers(new AntPathRequestMatcher("/_proxy/**/*")) .ignoringRequestMatchers(new AntPathRequestMatcher("/_proxy/**/*"))
.ignoringRequestMatchers(new AntPathRequestMatcher("/api/session/check-duplicate"))
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/migration/**")) .ignoringRequestMatchers(new AntPathRequestMatcher("/internal/migration/**"))
) )
// 로그인 페이지에 오래 머물러 세션(=CSRF 토큰 저장소)이 타임아웃되면 // 로그인 페이지에 오래 머물러 세션(=CSRF 토큰 저장소)이 타임아웃되면
@@ -1,6 +1,5 @@
package com.eactive.apim.portal.custom.config; package com.eactive.apim.portal.custom.config;
//import com.eactive.ext.djb.safedb.DjbSafedbWrapper;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
@@ -17,8 +16,5 @@ public class DjbPasswordEncoder implements PasswordEncoder {
@Override @Override
public boolean matches(CharSequence rawPassword, String encodedPassword) { public boolean matches(CharSequence rawPassword, String encodedPassword) {
return bcryptEncoder.matches(rawPassword, encodedPassword); return bcryptEncoder.matches(rawPassword, encodedPassword);
// DjbSafedbWrapper safedb = DjbSafedbWrapper.getInstance();
// String bcryptHash = safedb.decryptNotRnno(encodedPassword);
// return bcryptEncoder.matches(rawPassword, bcryptHash);
} }
} }
+16 -16
View File
@@ -366,38 +366,38 @@ page:
name: "개발자 정보" name: "개발자 정보"
path: "/users/detail" path: "/users/detail"
apikey: apikey:
name: " 관리" name: "API 신청 관리"
path: "/myapikey" path: "/clients"
app_request_detail: app_request_detail:
name: "인증키 신청 상세" name: "인증키 신청 상세"
path: "/myapikey/app_request_detail" path: "/clients/app_request_detail"
credential_detail: credential_detail:
name: "인증키 정보" name: "인증키 정보"
path: "/myapikey/credential_detail" path: "/clients/credential_detail"
password_verify: password_verify:
name: "비밀번호 변경" name: "비밀번호 변경"
path: "/password/verify" path: "/password/verify"
password_change: password_change:
name: "비밀번호 변경" name: "비밀번호 변경"
path: "/password/change" path: "/password/change"
myapikey_register_step1: clients_register_step1:
name: "앱 생성 (기본 정보)" name: "앱 생성 (기본 정보)"
path: "/myapikey/register/step1" path: "/clients/register/step1"
myapikey_register_step2: clients_register_step2:
name: "앱 생성 (API 선택)" name: "앱 생성 (API 선택)"
path: "/myapikey/register/step2" path: "/clients/register/step2"
myapikey_register_step3: clients_register_step3:
name: "앱 생성 요청 완료" name: "앱 생성 요청 완료"
path: "/myapikey/register/step3" path: "/clients/register/step3"
myapikey_modify_step1: clients_modify_step1:
name: "앱 수정 (기본 정보)" name: "앱 수정 (기본 정보)"
path: "/myapikey/modify/step1" path: "/clients/modify/step1"
myapikey_modify_step2: clients_modify_step2:
name: "앱 수정 (API 선택)" name: "앱 수정 (API 선택)"
path: "/myapikey/modify/step2" path: "/clients/modify/step2"
myapikey_modify_step3: clients_modify_step3:
name: "앱 수정 요청 완료" name: "앱 수정 요청 완료"
path: "/myapikey/modify/step3" path: "/clients/modify/step3"
api_statistics: api_statistics:
name: "이용 통계" name: "이용 통계"
path: "/statistics/api" path: "/statistics/api"
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -449,7 +449,7 @@ document.addEventListener('DOMContentLoaded', function() {
// Form submit // Form submit
// - Webhook/앱 변경은 API 가 필수(form data-api-required=true)이므로 미선택 시 차단한다. // - Webhook/앱 변경은 API 가 필수(form data-api-required=true)이므로 미선택 시 차단한다.
// - 앱 신청(myapikey register)은 선택 사항이라, 미선택 시 확인 팝업으로 한 번 더 묻고 진행한다. // - 앱 신청(clients register)은 선택 사항이라, 미선택 시 확인 팝업으로 한 번 더 묻고 진행한다.
form.addEventListener('submit', function(e) { form.addEventListener('submit', function(e) {
if (selectedApis.size !== 0) { if (selectedApis.size !== 0) {
syncHiddenSelected(); syncHiddenSelected();
+81
View File
@@ -0,0 +1,81 @@
/*!
* DjbToast — 경량 전역 토스트 알림.
* 사용: window.djbToast('메시지') 또는 djbToast('메시지', { type: 'info', duration: 3000 })
*
* 추가로, 페이지 진입 시 URL 쿼리 `apiApplyToast` 값에 따라 안내 토스트를 자동 노출한다.
* - new : 신규 클라이언트 생성 페이지 진입 안내
* - existing : 클라이언트 목록(선택) 진입 안내
* - modify : 클라이언트 1건 보유 → 수정 플로우 API 선택 직행 안내
* 토스트 노출 후 쿼리를 정리(replaceState)해 새로고침 시 재노출을 막는다.
*/
(function (global) {
"use strict";
var CONTAINER_ID = "djb-toast-container";
function ensureContainer() {
var el = document.getElementById(CONTAINER_ID);
if (!el) {
el = document.createElement("div");
el.id = CONTAINER_ID;
el.className = "djb-toast-container";
document.body.appendChild(el);
}
return el;
}
function showToast(message, opts) {
if (!message) return;
opts = opts || {};
var duration = typeof opts.duration === "number" ? opts.duration : 3200;
var type = opts.type || "info";
var container = ensureContainer();
var toast = document.createElement("div");
toast.className = "djb-toast djb-toast--" + type;
toast.setAttribute("role", "status");
toast.innerHTML = String(message); // 호출부에서만 신뢰 문자열 전달
container.appendChild(toast);
// enter 애니메이션
requestAnimationFrame(function () { toast.classList.add("is-show"); });
var remove = function () {
toast.classList.remove("is-show");
setTimeout(function () {
if (toast.parentNode) toast.parentNode.removeChild(toast);
}, 250);
};
var timer = setTimeout(remove, duration);
toast.addEventListener("click", function () { clearTimeout(timer); remove(); });
}
global.djbToast = showToast;
// ── URL 쿼리 기반 자동 안내 토스트 ──
var API_APPLY_MESSAGES = {
"new": "API 를 사용할 클라이언트 생성을 먼저 진행해주세요.",
"existing": "API 를 추가 사용할 클라이언트를 선택해주세요",
"modify": "보유 클라이언트에 추가 사용할 API 를 선택해주세요"
};
function handleApiApplyToast() {
var params = new URLSearchParams(global.location.search);
var key = params.get("apiApplyToast");
if (!key || !API_APPLY_MESSAGES[key]) return;
showToast(API_APPLY_MESSAGES[key], { type: "info" });
// 쿼리 제거 후 URL 정리 (새로고침 재노출 방지)
params.delete("apiApplyToast");
var qs = params.toString();
var newUrl = global.location.pathname + (qs ? "?" + qs : "") + global.location.hash;
try { global.history.replaceState(null, "", newUrl); } catch (e) { /* noop */ }
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", handleApiApplyToast);
} else {
handleApiApplyToast();
}
})(window);
@@ -31,6 +31,7 @@ var LoginSuccessHandler = (function() {
pendingInvitation: false, pendingInvitation: false,
pendingInvitationToken: '', pendingInvitationToken: '',
pendingInvitationOrgName: '', pendingInvitationOrgName: '',
needClientRegister: false,
sessionSuccessMsg: '', sessionSuccessMsg: '',
redirectUrl: '' redirectUrl: ''
}; };
@@ -117,6 +118,19 @@ var LoginSuccessHandler = (function() {
} }
} }
); );
return; // 다른 체크 중단
}
// 6. 클라이언트(앱/API키) 미보유 법인 사용자 → 신규 신청 유도 (로그인 직후 1회)
if (config.needClientRegister) {
customPopups.showConfirm(
'API 사용을 위해서는 클라이언트 신규 신청이 필요합니다.<br>지금 신청하시겠습니까?',
function(selection) {
if (selection) {
window.location.href = '/clients/register/step1';
}
}
);
} }
} }
+60 -10
View File
@@ -2,8 +2,10 @@
* 비밀번호 문자열 정책 라이브 검증 (공용) * 비밀번호 문자열 정책 라이브 검증 (공용)
* *
* 서버 검증기 PasswordRuleValidator.isValid(= @PasswordRule) 의 * 서버 검증기 PasswordRuleValidator.isValid(= @PasswordRule) 의
* "문자열" 규칙을 그대로 클라이언트로 포팅한다. 아이디/휴대전화 포함 여부는 * 규칙을 클라이언트로 포팅한다. 아이디/휴대전화 포함 규칙(noid/nomobile)은
* 민감정보 노출을 피하기 위해 서버 검증에만 맡긴다. * 사용자가 폼에 직접 입력한 값이 페이지에 이미 있을 때만 ul 의
* data-context-loginid/data-context-mobile 로 연결해 쓴다 — DB 값을 새로
* 내려받아야 하는 화면(비밀번호 변경)은 서버 AJAX(/password/content-check)로 판정한다.
* *
* 사용법(마크업 구동): * 사용법(마크업 구동):
* <ul class="password-policy-checklist" data-password-input="newPassword"> * <ul class="password-policy-checklist" data-password-input="newPassword">
@@ -31,7 +33,7 @@
return false; return false;
} }
// 규칙별 판정 함수 (통과=true) // 규칙별 판정 함수 (통과=true). ctx = { loginId, mobile } — 값이 없으면 해당 규칙은 통과 처리
var RULES = { var RULES = {
length: function (pw) { return pw.length >= 8 && pw.length <= 50; }, length: function (pw) { return pw.length >= 8 && pw.length <= 50; },
letter: function (pw) { return /[a-zA-Z]/.test(pw); }, letter: function (pw) { return /[a-zA-Z]/.test(pw); },
@@ -39,23 +41,46 @@
special: function (pw) { return /[^A-Za-z0-9_]/.test(pw); }, // 서버 정규식 \W 기준 (밑줄 제외) special: function (pw) { return /[^A-Za-z0-9_]/.test(pw); }, // 서버 정규식 \W 기준 (밑줄 제외)
nospace: function (pw) { return !/\s/.test(pw); }, nospace: function (pw) { return !/\s/.test(pw); },
norepeat: function (pw) { return !/(\w)\1\1/.test(pw.toUpperCase()); }, norepeat: function (pw) { return !/(\w)\1\1/.test(pw.toUpperCase()); },
noseq: function (pw) { return !hasSequential(pw); } noseq: function (pw) { return !hasSequential(pw); },
// 아이디(이메일 local part) 포함 금지 — 서버 PasswordRuleValidator.containsLoginIdLocalPart 포팅
noid: function (pw, ctx) {
var id = ctx && ctx.loginId ? String(ctx.loginId).split('@')[0].toUpperCase() : '';
return !id || pw.toUpperCase().indexOf(id) === -1;
},
// 휴대전화 하이픈 세그먼트 포함 금지 — 서버 containsMobileSegment 포팅
nomobile: function (pw, ctx) {
var m = ctx && ctx.mobile ? String(ctx.mobile) : '';
if (!m) { return true; }
var up = pw.toUpperCase();
var parts = m.split('-');
for (var i = 0; i < parts.length; i++) {
if (parts[i] && up.indexOf(parts[i]) !== -1) {
return false;
}
}
return true;
}
}; };
// 전체 문자열 규칙 통과 여부 // 전체 문자열 규칙 통과 여부 (ctx 미전달 시 noid/nomobile 은 통과 — 서버 검증에 위임)
function isValid(pw) { function isValid(pw, ctx) {
if (!pw) { if (!pw) {
return false; return false;
} }
for (var key in RULES) { for (var key in RULES) {
if (RULES.hasOwnProperty(key) && !RULES[key](pw)) { if (RULES.hasOwnProperty(key) && !RULES[key](pw, ctx || {})) {
return false; return false;
} }
} }
return true; return true;
} }
// 체크리스트(ul) 하나를 대상 input 에 바인딩 // bind 된 체크리스트들의 update 함수 목록 (컨텍스트 값 변경 시 refresh 용)
var updaters = [];
// 체크리스트(ul) 하나를 대상 input 에 바인딩.
// ul 의 data-context-loginid / data-context-mobile 속성에 소스 input 의 id 를 주면
// noid/nomobile 규칙이 해당 값 기준으로 라이브 판정된다.
function bind(input, list) { function bind(input, list) {
var $input = (input && input.jquery) ? input : $(input); var $input = (input && input.jquery) ? input : $(input);
var $list = (list && list.jquery) ? list : $(list); var $list = (list && list.jquery) ? list : $(list);
@@ -64,8 +89,18 @@
return; return;
} }
function ctxValue(attr) {
var id = $list.attr(attr);
var el = id ? document.getElementById(id) : null;
return el ? el.value : '';
}
function update() { function update() {
var pw = $input.val() || ''; var pw = $input.val() || '';
var ctx = {
loginId: ctxValue('data-context-loginid'),
mobile: ctxValue('data-context-mobile')
};
$items.each(function () { $items.each(function () {
var $li = $(this); var $li = $(this);
var rule = RULES[$li.attr('data-rule')]; var rule = RULES[$li.attr('data-rule')];
@@ -76,15 +111,29 @@
if (pw.length === 0) { if (pw.length === 0) {
$li.addClass('is-idle'); $li.addClass('is-idle');
} else { } else {
$li.addClass(rule(pw) ? 'is-pass' : 'is-fail'); $li.addClass(rule(pw, ctx) ? 'is-pass' : 'is-fail');
} }
}); });
} }
// 컨텍스트 소스 input 이 직접 타이핑되는 경우도 즉시 반영
['data-context-loginid', 'data-context-mobile'].forEach(function (attr) {
var id = $list.attr(attr);
if (id && document.getElementById(id)) {
$(document.getElementById(id)).on('input.passwordPolicy change.passwordPolicy', update);
}
});
updaters.push(update);
$input.on('input.passwordPolicy', update); $input.on('input.passwordPolicy', update);
update(); update();
} }
// hidden input 등 이벤트 없이 값이 세팅되는 컨텍스트 변경 후 수동 재판정
function refresh() {
updaters.forEach(function (u) { u(); });
}
// 마크업 구동 자동 초기화 // 마크업 구동 자동 초기화
function init(root) { function init(root) {
var $root = root ? $(root) : $(document); var $root = root ? $(root) : $(document);
@@ -101,7 +150,8 @@
RULES: RULES, RULES: RULES,
isValid: isValid, isValid: isValid,
bind: bind, bind: bind,
init: init init: init,
refresh: refresh
}; };
$(function () { $(function () {
@@ -133,6 +133,7 @@ const customPopups = {
// 기본값 설정 // 기본값 설정
const title = options.title || '비밀번호 입력'; const title = options.title || '비밀번호 입력';
const message = options.message || '계속하려면 비밀번호를 입력해주세요.'; const message = options.message || '계속하려면 비밀번호를 입력해주세요.';
const placeholder = options.placeholder || '비밀번호를 입력하세요';
const onConfirm = options.onConfirm; const onConfirm = options.onConfirm;
const onCancel = options.onCancel; const onCancel = options.onCancel;
@@ -140,8 +141,8 @@ const customPopups = {
$('#passwordPopupTitle').text(title); $('#passwordPopupTitle').text(title);
$('#passwordPopupMessage').html(customPopups._sanitizeHtml(message)); $('#passwordPopupMessage').html(customPopups._sanitizeHtml(message));
// 입력 필드 및 에러 초기화 // 입력 필드 및 에러 초기화 (placeholder 는 호출부 커스텀 허용)
$('#passwordPopupInput').val('').removeClass('error'); $('#passwordPopupInput').val('').removeClass('error').attr('placeholder', placeholder);
$('#passwordPopupError').removeClass('show').text(''); $('#passwordPopupError').removeClass('show').text('');
// 팝업 표시 (modal 구조 사용) // 팝업 표시 (modal 구조 사용)
@@ -406,8 +407,12 @@ const customPopups = {
return; return;
} }
// 하이픈 유무 무관 입력을 저장 표준(010-1234-5678)으로 통일해 전달
const formattedMobile = mobile.replace(/-/g, '')
.replace(/^(01[016-9])(\d{3,4})(\d{4})$/, '$1-$2-$3');
if (typeof onConfirm === 'function') { if (typeof onConfirm === 'function') {
onConfirm(mobile, notifyConsent); onConfirm(formattedMobile, notifyConsent);
} }
}); });
@@ -672,9 +677,36 @@ const customPopups = {
$('body').css('overflow', 'hidden'); $('body').css('overflow', 'hidden');
// 열 때마다 사유/에러 초기화
$('#withdrawalReasonInput').val('');
$('#withdrawalReasonError').hide();
$('#withdrawalReasonInput').off('input.withdrawal').on('input.withdrawal', function () {
$('#withdrawalReasonError').hide();
});
$('#withdrawalPopupConfirmButton').off('click').on('click', function () { $('#withdrawalPopupConfirmButton').off('click').on('click', function () {
// 탈퇴 사유 필수 입력
var reason = $.trim($('#withdrawalReasonInput').val() || '');
if (!reason) {
$('#withdrawalReasonError').show();
$('#withdrawalReasonInput').focus();
return;
}
$('#withdrawalReasonHidden').val(reason);
customPopups.hideWithdrawal(); customPopups.hideWithdrawal();
// step-up 2FA 활성 시: 인증 성공 후에만 제출
var twofaRequired = $('#withdrawalPopup').attr('data-twofa-required') === 'true';
if (twofaRequired && typeof TwoFactorAuth !== 'undefined') {
TwoFactorAuth.open({
purpose: '/withdraw',
onSuccess: function () { $('#withdrawalForm').submit(); },
onCancel: function () { /* 사용자 취소 — 탈퇴 중단 */ }
});
} else {
$('#withdrawalForm').submit(); $('#withdrawalForm').submit();
}
}); });
$('#withdrawalPopupCancelButton').off('click').on('click', function () { $('#withdrawalPopupCancelButton').off('click').on('click', function () {
@@ -3,7 +3,7 @@
* *
* TwoFactorAuth.open({ * TwoFactorAuth.open({
* mode: 'login' | 'stepup', // 참고용(서버가 세션으로 판별). 로깅/분기용 * mode: 'login' | 'stepup', // 참고용(서버가 세션으로 판별). 로깅/분기용
* purpose: '/myapikey/...', // step-up 대상 보호 경로(로그인은 생략) * purpose: '/clients/...', // step-up 대상 보호 경로(로그인은 생략)
* onSuccess: function(res){}, // 검증 성공. login 이면 res.redirect 사용 * onSuccess: function(res){}, // 검증 성공. login 이면 res.redirect 사용
* onCancel: function(reason){}// 닫기/타임아웃/실패로 종료 * onCancel: function(reason){}// 닫기/타임아웃/실패로 종료
* }); * });
@@ -53,7 +53,7 @@
'op.tryItOut': { en: 'Try it out', ko: '실행해보기' }, 'op.tryItOut': { en: 'Try it out', ko: '실행해보기' },
'op.cancel': { en: 'Cancel', ko: '취소' }, 'op.cancel': { en: 'Cancel', ko: '취소' },
'op.execute': { en: 'Execute', ko: '실행' }, 'op.execute': { en: 'Execute', ko: '실행' },
'op.clear': { en: 'Clear', ko: '지우기' }, 'op.clear': { en: 'Clear', ko: 'Reset (응답지우기)' },
'op.reset': { en: 'Reset', ko: '초기화' }, 'op.reset': { en: 'Reset', ko: '초기화' },
'op.parameters': { en: 'Parameters', ko: '파라미터' }, 'op.parameters': { en: 'Parameters', ko: '파라미터' },
'op.parameter': { en: 'Parameter', ko: '파라미터' }, 'op.parameter': { en: 'Parameter', ko: '파라미터' },
@@ -225,6 +225,18 @@
+ '</div>'; + '</div>';
} }
// Execute 후 응답(또는 검증 실패) 영역으로 자동 스크롤. 성공/검증실패 모두
// .djb-response-grid 로 수렴하므로 이 그리드를 뷰포트 상단으로 올린다.
function scrollToGrid(op) {
setTimeout(function () {
var grid = (op || document).querySelector(".djb-response-grid")
|| document.querySelector("#swagger-ui .djb-response-grid");
if (!grid) return;
try { grid.scrollIntoView({ behavior: "smooth", block: "start" }); }
catch (e) { grid.scrollIntoView(); }
}, 60);
}
// Execute 클릭 시점에 대상 opblock 확정 + (검증) + 타이머 시작 + 빈 그리드 표시. // Execute 클릭 시점에 대상 opblock 확정 + (검증) + 타이머 시작 + 빈 그리드 표시.
// 캡처 단계라 Swagger(React) 의 실행 핸들러보다 먼저 실행됨 → 검증 실패 시 // 캡처 단계라 Swagger(React) 의 실행 핸들러보다 먼저 실행됨 → 검증 실패 시
// stopImmediatePropagation 으로 native 실행을 차단하고 에러만 렌더한다. // stopImmediatePropagation 으로 native 실행을 차단하고 에러만 렌더한다.
@@ -247,6 +259,7 @@
e.preventDefault(); e.preventDefault();
e.stopImmediatePropagation(); e.stopImmediatePropagation();
renderValidationErrors(result.errors); renderValidationErrors(result.errors);
scrollToGrid(targetOpblock);
return; return;
} }
} }
@@ -261,6 +274,7 @@
grid.classList.remove("djb-error"); grid.classList.remove("djb-error");
grid.innerHTML = renderEmpty(); grid.innerHTML = renderEmpty();
} }
scrollToGrid(targetOpblock);
} }
/** /**
@@ -225,6 +225,15 @@
withObserverPaused(function () { renderInto(panelEl()); }); withObserverPaused(function () { renderInto(panelEl()); });
return; return;
} }
// Execute(실행) 클릭 시: Swagger 가 요청 DOM/값을 갱신한 뒤 현재 탭을 재렌더.
// (검증 실패는 response.js 캡처가 stopImmediatePropagation 으로 차단 → 여기 도달 안 함)
// Swagger 의 opblock-body 재렌더 타이밍이 유동적이라 짧게 두 번 갱신한다.
var exec = e.target.closest && e.target.closest(".btn.execute");
if (exec) {
setTimeout(function () { withObserverPaused(function () { renderInto(panelEl()); }); }, 80);
setTimeout(function () { withObserverPaused(function () { renderInto(panelEl()); }); }, 300);
return;
}
var copy = e.target.closest && e.target.closest('#djb-snippet-panel [data-action="copy"]'); var copy = e.target.closest && e.target.closest('#djb-snippet-panel [data-action="copy"]');
if (copy) { if (copy) {
var code = document.querySelector("#djb-snippet-panel .djb-sn-code"); var code = document.querySelector("#djb-snippet-panel .djb-sn-code");
@@ -41,6 +41,40 @@
.swagger-ui .responses-inner > div { display: none !important; } .swagger-ui .responses-inner > div { display: none !important; }
.swagger-ui .live-responses-table { display: none !important; } .swagger-ui .live-responses-table { display: none !important; }
/* ─── 서버 영역(.scheme-container) ─────────────────────────────────────────────
"서버" 셀렉트박스(라벨+select)는 숨긴다 — 요청 URL 은 spec servers[0] 값을 그대로
사용하므로 UI 만 제거해도 호출에는 영향 없음. 대신 이 영역 안으로 "앱 선택" 패널
(#appsWrap)을 JS 로 이관(relocate)해 배치한다. */
.swagger-ui .scheme-container .servers-title,
.swagger-ui .scheme-container .servers,
.swagger-ui .scheme-container .schemes-server-container { display: none !important; }
/* 앱 선택(#appsWrap)과 인증(.auth-wrapper) 을 한 행에 8:2 로 배치 */
.swagger-ui .scheme-container .schemes {
flex-wrap: nowrap;
align-items: stretch;
gap: 16px;
}
.swagger-ui .scheme-container #appsWrap {
order: 1;
flex: 8 1 0;
min-width: 0; /* select 축약(truncate) 위해 shrink 허용 */
margin: 0;
}
.swagger-ui .scheme-container .auth-wrapper {
order: 2;
flex: 2 1 0;
min-width: 0;
align-items: center;
justify-content: stretch;
}
.swagger-ui .scheme-container .auth-wrapper .authorize {
width: 100%;
justify-content: center;
margin: 0;
padding-right: 0;
}
/* ─── 상시 요청 스니펫 패널 (PoC overlay.css #djb-snippet-panel) ─────────────── */ /* ─── 상시 요청 스니펫 패널 (PoC overlay.css #djb-snippet-panel) ─────────────── */
.swagger-ui #djb-snippet-panel { .swagger-ui #djb-snippet-panel {
margin: 16px; margin: 16px;
@@ -598,6 +598,14 @@
color: #fff; color: #fff;
} }
&:disabled {
background: #cbd5e1 !important;
color: #94a3b8 !important;
cursor: not-allowed !important;
transform: none !important;
pointer-events: none !important;
}
&.md { &.md {
font-size: 14px; font-size: 14px;
padding: 11px 45px; padding: 11px 45px;
@@ -478,7 +478,7 @@ select.form-control {
&:disabled, &:disabled,
&.input-readonly { &.input-readonly {
background: $gray-bg; background: #e9ecef !important;
color: $text-gray; color: $text-gray;
cursor: not-allowed; cursor: not-allowed;
} }
@@ -29,15 +29,15 @@
line-height: 1; line-height: 1;
&::before { &::before {
content: "\2022"; // • content: "";
} }
} }
&.is-idle { &.is-idle {
color: #888; color: #718096;
.policy-icon { .policy-icon {
color: #b5b5b5; color: #a0aec0;
} }
} }
@@ -71,5 +71,5 @@
margin: 10px 0 0 0; margin: 10px 0 0 0;
font-size: 14px; font-size: 14px;
line-height: 18px; line-height: 18px;
color: #888; color: #ef5555;
} }
@@ -0,0 +1,68 @@
// ── DjbToast (전역 토스트 알림) ────────────────────────────────
// js/djb/toast.js 와 연동. 화면 우하단 스택 노출.
.djb-toast-container {
position: fixed;
top: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 12000;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
pointer-events: none;
@media (max-width: 768px) {
top: 12px;
left: 12px;
right: 12px;
transform: none;
align-items: stretch;
}
}
.djb-toast {
pointer-events: auto;
min-width: 260px;
max-width: 380px;
padding: 14px 18px;
border-radius: 10px;
background: #f4f7fc;
color: #1f2328;
font-size: 14px;
line-height: 1.5;
border: 1px solid #e3e8f0;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
cursor: pointer;
opacity: 0;
transform: translateY(-12px);
transition: opacity 0.22s ease, transform 0.22s ease;
&.is-show {
opacity: 1;
transform: translateY(0);
}
// 좌측 강조 바
border-left: 4px solid #0049B4;
&--info { border-left-color: #0049B4; }
&--success { border-left-color: #1f883d; }
&--warn { border-left-color: #bf8700; }
&--error { border-left-color: #cf222e; }
@media (max-width: 768px) {
max-width: none;
}
// 데스크탑: 크기 약 30% 확대 (가독성)
@media (min-width: 769px) {
min-width: 338px;
max-width: 494px;
padding: 18px 24px;
font-size: 18px;
border-radius: 13px;
}
}
@@ -203,9 +203,8 @@ $tfa-danger: #F4253C;
.tfa-code-input-wrap { .tfa-code-input-wrap {
flex: 1; flex: 1;
display: flex; display: flex;
align-items: center; align-items: stretch;
height: 56px; height: 56px;
padding: 0 14px;
border: 1px solid $tfa-border-2; border: 1px solid $tfa-border-2;
border-radius: 4px; border-radius: 4px;
background: #fff; background: #fff;
@@ -217,6 +216,8 @@ $tfa-danger: #F4253C;
.tfa-code-input { .tfa-code-input {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
height: 100%;
padding: 0 14px;
border: 0; border: 0;
outline: none; outline: none;
background: transparent; background: transparent;
@@ -913,7 +913,7 @@
.nav-menu { .nav-menu {
display: flex; display: flex;
list-style: none; list-style: none;
gap: 28px; gap: 24px;
margin: 0; margin: 0;
padding: 0; padding: 0;
@@ -925,6 +925,7 @@
>.nav-link { >.nav-link {
background: var(--light-bg); background: var(--light-bg);
color: var(--primary-blue); color: var(--primary-blue);
border-radius: 9px;
} }
.sub-menu { .sub-menu {
@@ -978,7 +979,7 @@
font-size: 16px; font-size: 16px;
font-weight: 500; font-weight: 500;
color: var(--text-gray); color: var(--text-gray);
padding: 8px; padding: 12px;
position: relative; position: relative;
transition: var(--transition-smooth); transition: var(--transition-smooth);
+3
View File
@@ -34,18 +34,21 @@
@use 'components/partners' as *; @use 'components/partners' as *;
@use 'components/cta' as *; @use 'components/cta' as *;
@use 'components/password-popup' as *; @use 'components/password-popup' as *;
@use 'components/password-policy' as *;
@use 'components/header-auth' as *; @use 'components/header-auth' as *;
@use 'components/session-timer' as *; @use 'components/session-timer' as *;
@use 'components/tables' as *; @use 'components/tables' as *;
@use 'components/accordion' as *; @use 'components/accordion' as *;
@use 'components/page-title-banner' as *; @use 'components/page-title-banner' as *;
@use 'components/alerts' as *; @use 'components/alerts' as *;
@use 'components/toast' as *;
@use 'components/pagination' as *; @use 'components/pagination' as *;
@use 'components/breadcrumb' as *; @use 'components/breadcrumb' as *;
@use 'components/test-env-notice' as *; @use 'components/test-env-notice' as *;
@use 'components/djb-inquiry-comments' as *; @use 'components/djb-inquiry-comments' as *;
@use 'components/board-common' as *; @use 'components/board-common' as *;
@use 'components/two-factor' as *; @use 'components/two-factor' as *;
@use 'components/password-policy' as *;
// 5. Page-specific styles // 5. Page-specific styles
@use 'pages/index' as *; @use 'pages/index' as *;
@@ -9,33 +9,35 @@
.account-recovery-page { .account-recovery-page {
display: flex; display: flex;
align-items: flex-start; align-items: center;
justify-content: center; justify-content: center;
background: transparent; padding: 100px 20px;
padding: 0;
position: relative; position: relative;
min-height: auto; border-radius: 12px;
margin: 0; margin-bottom: 50px;
} }
.account-recovery-container { .account-recovery-container {
width: 100%; width: 100%;
max-width: 800px; max-width: 650px;
margin: 0 auto; margin: 0 auto;
position: relative; position: relative;
padding: $spacing-lg; padding: $spacing-lg;
} }
.account-recovery-card { .account-recovery-card {
background: transparent; background: #FFFFFF;
padding: 0; border-radius: 20px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.10);
padding: 36px 36px 28px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
position: relative; position: relative;
@media (max-width: 576px) { @media (max-width: 576px) {
padding: 0 20px; padding: 24px 20px 20px;
border-radius: 16px;
} }
} }
@@ -49,14 +51,13 @@
display: none; display: none;
} }
// Tab Navigation (Figma 디자인: 약관 페이지와 동일) // Tab Navigation (Design 2: 클래식 폴더 탭)
.account-recovery-tabs { .account-recovery-tabs {
display: flex; display: flex;
gap: 0; gap: 0;
margin-bottom: 0; margin-bottom: 28px;
width: 100%; width: 100%;
background: transparent; background: transparent;
border-radius: 0;
padding: 0; padding: 0;
.tab-link { .tab-link {
@@ -64,45 +65,46 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 14px 24px; padding: 14px 20px;
font-family: $font-family-primary; font-family: $font-family-primary;
font-size: 16px; font-size: 15px;
font-weight: 700; font-weight: 400;
color: #8c959f; color: #adb5bd;
text-decoration: none; text-decoration: none;
text-align: center; text-align: center;
background: #eceff4; background: #FFFFFF;
// 비활성 탭: 하단 border만 (바닥선 역할)
border: 1.5px solid transparent;
border-bottom: 1.5px solid #dee2e6;
border-radius: 0; border-radius: 0;
transition: all 0.3s ease; transition: all 0.2s ease;
cursor: pointer;
// 왼쪽 탭 둥근 모서리 position: relative;
&:first-child {
border-radius: 30px 0 0 0;
}
// 오른쪽 탭 둥근 모서리
&:last-child {
border-radius: 0 30px 0 0;
}
&:hover { &:hover {
color: #3ba4ed; color: $primary-blue;
background: #e4e8ed;
} }
&.active { &.active {
color: #FFFFFF; color: $primary-blue;
background: #3ba4ed;
font-weight: 700; font-weight: 700;
// 활성 탭: 상/좌/우 파란 테두리 + 하단 흰색으로 바닥선 가려 콘텐츠와 연결
border-top: 1.5px solid $primary-blue;
border-left: 1.5px solid $primary-blue;
border-right: 1.5px solid $primary-blue;
border-bottom: 2px solid #FFFFFF;
border-radius: 8px 8px 0px 0px;
} }
@media (max-width: 576px) { @media (max-width: 576px) {
padding: 12px 16px; padding: 12px 10px;
font-size: 14px; font-size: 13px;
} }
} }
} }
// Alert Messages // Alert Messages
.account-alert { .account-alert {
width: 100%; width: 100%;
@@ -140,73 +142,45 @@
} }
} }
// Form Content Area (Figma: 아이디찾기BG_box) // Form Content Area (세로 스택형, 라벨 없는 심플 스타일)
.account-recovery-form { .account-recovery-form {
width: 100%; width: 100%;
margin-bottom: 0; margin-bottom: 0;
background: #F6F9FB; background: transparent;
padding: 40px; padding: 0;
border-radius: 0 0 12px 12px;
@media (max-width: 576px) {
padding: 24px 16px;
}
.form-group { .form-group {
display: flex; display: flex;
align-items: center; flex-direction: column;
gap: 20px; gap: 0;
margin-bottom: 20px; margin-bottom: 12px;
&:last-of-type { &:last-of-type {
margin-bottom: 0; margin-bottom: 0;
} }
@media (max-width: 768px) {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
} }
// 라벨은 숨김 (placeholder로 대체)
.form-label { .form-label {
display: flex; display: none;
align-items: center;
flex-shrink: 0;
width: 170px;
font-family: $font-family-primary;
font-size: 15px;
font-weight: 400;
color: #212529;
margin-bottom: 0;
.required {
color: #ed5b5b;
margin-left: 2px;
}
@media (max-width: 768px) {
width: 100%;
font-size: 14px;
}
} }
.form-input { .form-input {
flex: 1; width: 100%;
height: 40px; height: 48px;
padding: 0 16px; padding: 0 16px;
font-family: $font-family-primary; font-family: $font-family-primary;
font-size: 14px; font-size: 14px;
font-weight: 400; font-weight: 400;
color: #212529; color: #212529;
background: #FFFFFF; background: #FFFFFF;
border: 1px solid #dadada; border: 1px solid #E2E8F0;
border-radius: 8px; border-radius: 10px;
outline: none; outline: none;
transition: all 0.3s ease; transition: all 0.25s ease;
&::placeholder { &::placeholder {
color: #dadada; color: #B0B8C1;
} }
&:hover { &:hover {
@@ -215,7 +189,7 @@
&:focus { &:focus {
border-color: #3ba4ed; border-color: #3ba4ed;
box-shadow: 0 0 0 2px rgba(59, 164, 237, 0.1); box-shadow: 0 0 0 3px rgba(59, 164, 237, 0.12);
} }
&:disabled { &:disabled {
@@ -231,24 +205,24 @@
} }
.form-select { .form-select {
height: 40px; height: 48px;
padding: 0 32px 0 16px; padding: 0 24px 0 12px; // padding을 좀 더 좁혀서 내용이 더 잘 보이도록 수정
font-family: $font-family-primary; font-family: $font-family-primary;
font-size: 14px; font-size: 14px;
font-weight: 400; font-weight: 400;
color: #515151; color: #515151;
background: #FFFFFF; background: #FFFFFF;
border: 1px solid #dadada; border: 1px solid #E2E8F0;
border-radius: 8px; border-radius: 10px;
outline: none; outline: none;
cursor: pointer; cursor: pointer;
transition: all 0.3s ease; transition: all 0.25s ease;
appearance: none; appearance: none;
-webkit-appearance: none; -webkit-appearance: none;
-moz-appearance: none; -moz-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='5' viewBox='0 0 10 5'%3E%3Cpath fill='%231D1B20' d='M5 5L0 0h10z'/%3E%3C/svg%3E"); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='5' viewBox='0 0 10 5'%3E%3Cpath fill='%231D1B20' d='M5 5L0 0h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: right 12px center; background-position: right 8px center; // 화살표 아이콘 위치도 조정
background-size: 10px 5px; background-size: 10px 5px;
&:hover { &:hover {
@@ -257,7 +231,7 @@
&:focus { &:focus {
border-color: #3ba4ed; border-color: #3ba4ed;
box-shadow: 0 0 0 2px rgba(59, 164, 237, 0.1); box-shadow: 0 0 0 3px rgba(59, 164, 237, 0.12);
} }
&:disabled { &:disabled {
@@ -275,55 +249,99 @@
} }
} }
// Phone Input Group (Figma: 휴대폰번호+텍스트필드) // 인증 방식 선택 pill 탭 (비밀번호 초기화 탭 내부)
.reset-method-group {
margin-bottom: 16px !important;
}
.reset-method-tabs {
display: flex;
gap: 8px;
background: #f0f2f5;
border-radius: 10px;
padding: 4px;
.reset-method-tab {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
margin: 0;
input[type="radio"] {
display: none;
}
span {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 9px 12px;
font-family: $font-family-primary;
font-size: 13px;
font-weight: 600;
color: #8c959f;
border-radius: 7px;
transition: all 0.2s ease;
text-align: center;
}
input[type="radio"]:checked+span {
background: #FFFFFF;
color: #212529;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.09);
}
&:hover span {
color: #3ba4ed;
}
}
}
// Phone Input Group
.phone-input-group { .phone-input-group {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 6px;
flex: 1; width: 100%;
.phone-prefix { .phone-prefix {
width: 100px; width: 90px;
min-width: 90px; // select가 줄어들지 않도록 고정
flex-shrink: 0; flex-shrink: 0;
} }
.phone-middle, .phone-middle,
.phone-last { .phone-last {
width: 100px; flex: 1;
flex-shrink: 0; min-width: 0;
} }
.phone-separator { .phone-separator {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 8px; color: #B0B8C1;
height: 1px; font-size: 14px;
background: #515151;
flex-shrink: 0; flex-shrink: 0;
} width: 10px;
height: auto;
background: transparent;
@media (max-width: 768px) { &::before {
flex-wrap: wrap; content: '-';
gap: 6px;
.phone-prefix,
.phone-middle,
.phone-last {
flex: 1;
min-width: 60px;
}
.phone-separator {
width: 6px;
} }
} }
@media (max-width: 576px) { @media (max-width: 576px) {
.phone-prefix, flex-wrap: wrap;
.phone-middle, gap: 6px;
.phone-last {
width: 100%; .phone-prefix {
flex: 1;
width: auto;
} }
.phone-separator { .phone-separator {
@@ -332,7 +350,7 @@
} }
} }
// Auth Number Group (Figma: 인증번호+텍스트필드) // Auth Number Group
.auth-number-group { .auth-number-group {
margin-top: 0; margin-top: 0;
} }
@@ -341,95 +359,103 @@
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
flex: 1; width: 100%;
gap: 8px;
&.mg-y {
margin-top: 20px;
margin-bottom: 12px;
}
.auth-input { .auth-input {
flex: 1; flex: 1;
padding-right: 60px; min-width: 0;
} }
.auth-timer { .auth-timer {
position: absolute; position: absolute;
right: 16px; right: 20px; // verify button width + gap
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
font-family: $font-family-primary; font-family: $font-family-primary;
font-size: 14px; font-size: 13px;
font-weight: 400; font-weight: 500;
color: #ed5b5b; color: #ed5b5b;
pointer-events: none; pointer-events: none;
@media (max-width: 576px) { @media (max-width: 576px) {
font-size: 12px; font-size: 12px;
right: 12px; right: 100px;
} }
} }
} }
// Buttons (Figma: 인증번호 받기/확인) // Buttons: 인증번호 받기 / 확인
.auth-request-button, .auth-request-button,
.auth-verify-button { .auth-verify-button {
width: 140px; height: 48px;
height: 40px; padding: 0 16px;
padding: 8px;
font-family: $font-family-primary; font-family: $font-family-primary;
font-size: 14px; font-size: 13px;
font-weight: 700; font-weight: 700;
color: #FFFFFF; color: #FFFFFF;
background: #a4d6ea; background: #3ba4ed;
border: none; border: none;
border-radius: 8px; border-radius: 10px;
cursor: pointer; cursor: pointer;
transition: all 0.3s ease; transition: all 0.25s ease;
margin: 0;
flex-shrink: 0; flex-shrink: 0;
white-space: nowrap;
line-height: 1; line-height: 1;
&:hover { &:hover {
background: darken(#a4d6ea, 5%); background: darken(#3ba4ed, 8%);
transform: none !important;
} }
&:active { &:active {
background: darken(#a4d6ea, 10%); background: darken(#3ba4ed, 14%);
} }
&:disabled { &:disabled {
opacity: 0.6; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
@media (max-width: 576px) { @media (max-width: 576px) {
width: 100%; width: 100%;
height: 40px; height: 44px;
font-size: 13px; font-size: 13px;
margin-top: 8px; margin-top: 4px;
} }
} }
// Form Actions (Figma: btn_취소, btn_신청) // Form Actions
.account-recovery-card .form-actions { .account-recovery-card .form-actions {
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: 20px; gap: 12px;
margin-top: 32px; margin-top: 28px;
padding: 32px 0; padding: 0;
border-top: none; border-top: none;
background: transparent; background: transparent;
.cancel-button, .cancel-button,
.submit-button { .submit-button {
width: 160px; flex: 1;
height: 40px; height: 48px;
padding: 8px; padding: 0 8px;
font-family: $font-family-primary; font-family: $font-family-primary;
font-size: 14px; font-size: 15px;
font-weight: 700; font-weight: 700;
border: none; border: none;
border-radius: 8px; border-radius: 10px;
cursor: pointer; cursor: pointer;
transition: all 0.3s ease; transition: all 0.25s ease;
line-height: 1; line-height: 1;
// <a> 태그 지원
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -438,14 +464,14 @@
.cancel-button { .cancel-button {
color: #5f666c; color: #5f666c;
background: #e5e7eb; background: #f0f2f5;
&:hover { &:hover {
background: darken(#e5e7eb, 5%); background: darken(#f0f2f5, 5%);
} }
&:active { &:active {
background: darken(#e5e7eb, 10%); background: darken(#f0f2f5, 10%);
} }
} }
@@ -454,7 +480,9 @@
background: #0049B4; background: #0049B4;
&:hover { &:hover {
background: darken(#0049B4, 5%); // background: darken(#0049B4, 5%);
background: rgb(6 54 125);
} }
&:active { &:active {
@@ -468,15 +496,12 @@
} }
@media (max-width: 576px) { @media (max-width: 576px) {
flex-direction: column;
gap: 10px; gap: 10px;
padding: 20px 0;
.cancel-button, .cancel-button,
.submit-button { .submit-button {
width: 100%; height: 44px;
height: 40px; font-size: 14px;
font-size: 13px;
} }
} }
} }
@@ -521,6 +546,7 @@
opacity: 0; opacity: 0;
transform: translateY(-10px); transform: translateY(-10px);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
@@ -545,7 +571,6 @@
// Result Page Styles (아이디 찾기 결과 페이지) // Result Page Styles (아이디 찾기 결과 페이지)
.account-recovery-result { .account-recovery-result {
width: 100%; width: 100%;
background: #F6F9FB;
padding: 60px 40px; padding: 60px 40px;
border-radius: 0 0 12px 12px; border-radius: 0 0 12px 12px;
text-align: center; text-align: center;
@@ -701,7 +726,7 @@
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Figma Mobile Design - node 1152-7047 (sm: 768px breakpoint) // Mobile Design (sm: 768px breakpoint)
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@include respond-to('sm') { @include respond-to('sm') {
.account-recovery-page { .account-recovery-page {
@@ -710,164 +735,78 @@
} }
.account-recovery-container { .account-recovery-container {
padding: 20px; padding: 16px;
} }
.account-recovery-card { .account-recovery-card {
padding: 0; padding: 24px 16px 20px;
border-radius: 16px;
} }
// Figma: 탭 - 167px × 40px, 14px Bold
.account-recovery-tabs { .account-recovery-tabs {
margin-bottom: 20px;
.tab-link { .tab-link {
height: 40px; padding: 9px 12px;
padding: 10px 16px; font-size: 13px;
font-size: 14px;
font-weight: 700;
&:first-child {
border-radius: 8px 0 0 0;
}
&:last-child {
border-radius: 0 8px 0 0;
}
} }
} }
// Figma: 폼 영역
.account-recovery-form { .account-recovery-form {
padding: 20px;
border-radius: 0 0 8px 8px;
.form-group { .form-group {
flex-direction: column; margin-bottom: 10px;
align-items: stretch;
gap: 8px;
margin-bottom: 16px;
} }
// Figma: 라벨 - 14px Medium
.form-label {
width: 100%;
font-size: 14px;
font-weight: 500;
margin-bottom: 0;
}
// Figma: 입력 필드 - 335px × 40px, border-radius 8px
.form-input { .form-input {
flex: none; height: 44px;
width: 100%; font-size: 13px;
height: 40px;
padding: 0 16px;
font-size: 12px;
border-radius: 8px;
&::placeholder {
color: #8c959f;
}
} }
// Figma: 셀렉트 - 40px 높이, 8px radius
.form-select { .form-select {
height: 40px; height: 44px;
padding: 0 32px 0 12px; font-size: 13px;
font-size: 12px;
border-radius: 8px;
background-position: right 10px center;
} }
} }
// 모바일: 휴대폰 번호 입력 - 화면 폭 채우기
.phone-input-group { .phone-input-group {
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 6px;
align-items: center;
width: 100%;
.phone-prefix { .phone-prefix {
flex: 1; flex: 1;
min-width: 0;
width: auto;
}
.phone-middle,
.phone-last {
flex: 1;
min-width: 0;
width: auto; width: auto;
} }
.phone-separator { .phone-separator {
display: flex; display: none;
width: auto;
height: auto;
background: transparent;
font-size: 12px;
color: #000;
&::before {
content: '-';
}
} }
} }
// 모바일: 인증번호 입력 그룹 - 화면 폭 채우기
.auth-input-group { .auth-input-group {
width: 100%;
.auth-input {
width: 100%;
padding-right: 60px;
}
// 타이머 - 12px, #ed5b5b
.auth-timer { .auth-timer {
font-size: 12px; font-size: 12px;
right: 12px; right: 100px;
} }
} }
// 모바일: 인증번호 받기/확인 버튼 - 화면 폭 채우기, 다음 줄에 배치
.auth-request-button, .auth-request-button,
.auth-verify-button { .auth-verify-button {
width: 100%; width: 100%;
height: 40px; height: 44px;
padding: 10px; font-size: 13px;
font-size: 14px; margin-top: 4px;
font-weight: 700;
border-radius: 8px;
margin-top: 8px;
} }
// Figma: 하단 버튼 - 144px × 40px, gap 21px, 가로 배치
.account-recovery-card .form-actions { .account-recovery-card .form-actions {
flex-direction: row; flex-direction: row;
gap: 21px; gap: 10px;
margin-top: 24px; margin-top: 20px;
padding: 24px 0;
.cancel-button, .cancel-button,
.submit-button { .submit-button {
flex: 1; flex: 1;
min-width: 144px; height: 44px;
height: 40px;
font-size: 14px; font-size: 14px;
font-weight: 700;
border-radius: 8px;
}
// Figma: 취소 버튼 - #e5e7eb 배경, #5f666c 텍스트
.cancel-button {
background: #e5e7eb;
color: #5f666c;
}
// Figma: 제출 버튼 - #0049b4 배경, 흰색 텍스트
.submit-button {
background: #0049b4;
color: #ffffff;
} }
} }
@@ -726,9 +726,9 @@
margin: $spacing-sm 0 0; margin: $spacing-sm 0 0;
padding: $spacing-lg; padding: $spacing-lg;
background: $white; background: $white;
border: 1px solid $border-gray; border: none;
border-radius: $border-radius-lg; border-radius: $border-radius-lg;
box-shadow: $shadow-sm; box-shadow: none;
.testbed-app-panel__head { .testbed-app-panel__head {
display: flex; display: flex;
@@ -846,6 +846,22 @@
} }
// API Overview Card (Flat Style) // API Overview Card (Flat Style)
/* "기본 정보" 헤더: 좌측 제목 + 우측 "API 사용 신청" 버튼 */
.api-basic-info-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-md;
flex-wrap: wrap;
}
/* 하단 중앙 "API 사용 신청" 버튼 */
.api-apply-footer {
display: flex;
justify-content: center;
margin-top: $spacing-xl;
}
.api-overview-card { .api-overview-card {
background: transparent; background: transparent;
border-radius: 0; border-radius: 0;
@@ -25,6 +25,12 @@
color: #4a5568; color: #4a5568;
} }
.statistics-notice-example {
flex-basis: 100%; /* flex-wrap 컨테이너에서 두 번째 줄로 강제 */
color: #6b7280;
font-size: 12px;
}
.statistics-notice-latest { .statistics-notice-latest {
color: #0049B4; color: #0049B4;
font-weight: 600; font-weight: 600;
@@ -3249,6 +3249,34 @@ input[type="checkbox"]:checked+.custom-checkbox {
} }
} }
// 선택 옵션 필드용 compact 변형: 높이 축소 + 가로 배치
.s1-upload-box--compact {
min-height: 96px;
.s1-upload-inner {
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
gap: 6px 14px;
padding: 12px 16px;
}
.s1-upload-inner > img {
width: 28px;
height: 28px;
}
.s1-upload-title {
font-size: 13px;
}
.s1-btn-upload {
height: 34px;
padding: 0 16px;
font-size: 13px;
}
}
.s1-upload-inner { .s1-upload-inner {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -159,7 +159,7 @@
.form-input:disabled, .form-input:disabled,
.input-readonly { .input-readonly {
background: #ededed; background: #e9ecef !important;
} }
.compound-input { .compound-input {
@@ -90,7 +90,7 @@
background: none; background: none;
padding: 0 0 $spacing-md 0; padding: 0 0 $spacing-md 0;
border-radius: 0; border-radius: 0;
border-bottom: 2px solid #212529; border-bottom: 1px solid #212529;
margin-bottom: $spacing-xl; margin-bottom: $spacing-xl;
h3 { h3 {
@@ -124,83 +124,78 @@
// Info Notice // Info Notice
.org-info-notice { .org-info-notice {
border-radius: $border-radius-md; background: #FFF5F5;
margin-bottom: $spacing-md; border: 1px solid #FED7D7;
border-radius: 8px;
padding: 14px 20px;
display: flex;
align-items: center;
gap: 12px;
margin-top: $spacing-md;
margin-bottom: $spacing-xl;
ul { .notice-icon-wrapper {
list-style: none; display: flex;
padding: 0; align-items: center;
justify-content: center;
color: #E53E3E;
flex-shrink: 0;
}
.notice-text {
font-family: $font-family-primary;
font-size: 14px;
font-weight: 500;
color: #C53030;
line-height: 1.5;
margin: 0; margin: 0;
li {
position: relative;
padding-left: $spacing-lg;
color: $text-gray;
font-size: $font-size-sm;
line-height: 1.6;
&::before {
content: '';
position: absolute;
left: 0;
color: $primary-blue;
font-weight: $font-weight-bold;
}
& + li {
margin-top: $spacing-sm;
}
}
} }
} }
// Form Groups // Form Groups
.org-form-group { .org-form-group {
margin-bottom: 16px; margin-bottom: 26px;
display: flex; display: flex;
align-items: flex-start; flex-direction: column;
gap: 20px; align-items: stretch;
gap: 13px;
&:last-child { &:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
@include respond-to('sm') {
flex-direction: column;
gap: 10px;
margin-bottom: 12px;
}
} }
.org-form-label { .org-form-label {
display: flex; display: flex;
align-items: center; align-items: center;
gap: $spacing-sm; gap: $spacing-sm;
font-size: 15px; font-family: $font-family-primary;
font-weight: $font-weight-regular; font-size: 16px;
color: $text-dark; font-weight: 700;
min-width: 160px; color: #1A202C;
padding-top: 10px; width: auto;
margin: 0;
padding: 0;
.required-badge { .required-badge {
display: inline-flex; visibility: hidden;
align-items: center; position: relative;
justify-content: center; width: 10px;
padding: 2px 8px;
background: #3BA4ED;
color: $white;
font-size: 12px;
font-weight: $font-weight-medium;
border-radius: 4px;
line-height: 1.2;
}
@include respond-to('sm') { &::after {
min-width: auto; content: '*';
padding-top: 0; visibility: visible;
position: absolute;
left: 4px;
top: 0;
color: #f4253c;
font-size: 16px;
font-weight: 700;
}
} }
} }
.org-form-input-wrapper { .org-form-input-wrapper {
flex: 1; flex: 1;
display: flex; display: flex;
@@ -217,6 +212,8 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: $spacing-md; gap: $spacing-md;
width: 100%;
max-width: 650px;
.org-form-input, .org-form-input,
.org-compound-input, .org-compound-input,
@@ -249,18 +246,20 @@
// Input Fields // Input Fields
.org-form-input { .org-form-input {
width: 100%; width: 100%;
padding: 8px 12px; padding: 10px 16px;
border: 1px solid $border-gray; border: 1px solid #CBD5E0;
border-radius: $border-radius-md; border-radius: 8px;
font-family: $font-family-primary;
font-size: 14px; font-size: 14px;
color: $text-dark; color: #1A202C;
transition: $transition-base; transition: all 0.3s ease;
background: $white; background: #FCFDFD;
&:focus { &:focus {
outline: none; outline: none;
border-color: $primary-blue; border-color: #0049B4; // Brand primary blue
box-shadow: 0 0 0 3px rgba(75, 155, 255, 0.1); box-shadow: 0 0 0 3px rgba(0, 73, 180, 0.15);
background: #FFFFFF;
} }
&:disabled { &:disabled {
@@ -278,7 +277,7 @@
} }
&::placeholder { &::placeholder {
color: $text-light; color: #A0AEC0;
} }
} }
@@ -289,9 +288,13 @@
gap: $spacing-sm; gap: $spacing-sm;
width: 100%; width: 100%;
&.phone-input-group {
flex: none;
width: auto;
}
.org-form-input, .org-form-input,
.org-form-select { .org-form-select {
flex: 1;
min-width: 0; min-width: 0;
} }
@@ -448,18 +451,23 @@
} }
.org-btn-check { .org-btn-check {
padding: 8px 16px; padding: 10px 20px;
width: auto; width: auto;
min-width: 100px; min-width: 100px;
background: #A4D6EA;
color: $white; color: $white;
border: 1px solid #A4D6EA; border-radius: 8px;
border-radius: $border-radius-md; font-family: $font-family-primary;
font-size: 14px; font-size: 14px;
font-weight: 700;
text-align: center; text-align: center;
justify-content: center; justify-content: center;
// transition: all 0.2s ease;
background: #3BA4ED;
border: 1px solid #3BA4ED;
&:not(:disabled):hover { &:not(:disabled):hover {
background: #008BC0;
border-color: #008BC0;
color: $white; color: $white;
} }
@@ -548,7 +556,7 @@
color: $text-gray; color: $text-gray;
line-height: 1.5; line-height: 1.5;
& + p { &+p {
margin-top: $spacing-xs; margin-top: $spacing-xs;
} }
@@ -564,10 +572,9 @@
.org-action-buttons { .org-action-buttons {
display: flex; display: flex;
gap: $spacing-lg; gap: $spacing-lg;
justify-content: center; justify-content: end;
margin-top: $spacing-4xl; margin-top: $spacing-4xl;
padding-top: $spacing-3xl; padding-top: $spacing-3xl;
border-top: 1px solid $border-gray;
.btn { .btn {
width: 220px; width: 220px;
@@ -627,15 +634,18 @@
padding: $spacing-sm $spacing-md; padding: $spacing-sm $spacing-md;
border-radius: $border-radius-sm; border-radius: $border-radius-sm;
font-size: $font-size-sm; font-size: $font-size-sm;
display: none;
&.success { &.success {
background: rgba(107, 207, 127, 0.1); background: rgba(107, 207, 127, 0.1);
color: $accent-green; color: $accent-green;
display: block;
} }
&.error { &.error {
background: rgba(255, 107, 107, 0.1); background: rgba(255, 107, 107, 0.1);
color: $accent-orange; color: $accent-orange;
display: block;
} }
} }
@@ -710,6 +720,7 @@
flex-direction: column; flex-direction: column;
gap: 20px; gap: 20px;
margin-bottom: 90px; margin-bottom: 90px;
font-family: $font-family-primary;
} }
// Agree All Section // Agree All Section
@@ -758,11 +769,11 @@
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
&:checked + .agreement-checkbox-custom { &:checked+.agreement-checkbox-custom {
background-image: url('/img/checkbox-checked.png'); background-image: url('/img/checkbox-checked.png');
} }
&:focus + .agreement-checkbox-custom { &:focus+.agreement-checkbox-custom {
box-shadow: 0 0 0 3px rgba(140, 149, 159, 0.1); box-shadow: 0 0 0 3px rgba(140, 149, 159, 0.1);
} }
} }
@@ -847,13 +858,15 @@
// Agreement Content // Agreement Content
.agreement-content { .agreement-content {
margin-top: $spacing-md; margin-top: $spacing-md;
background: $main-bg; background: #f9f9f9;
border-radius: 10px;
padding: 22px;
} }
.agreement-scroll { .agreement-scroll {
max-height: 300px; max-height: 400px;
overflow-y: auto; overflow-y: auto;
padding: $spacing-lg; // padding: $spacing-lg;
&::-webkit-scrollbar { &::-webkit-scrollbar {
width: 8px; width: 8px;
@@ -879,7 +892,12 @@
line-height: 1.8; line-height: 1.8;
color: $text-gray; color: $text-gray;
h1, h2, h3, h4, h5, h6 { h1,
h2,
h3,
h4,
h5,
h6 {
color: $text-dark; color: $text-dark;
margin-top: $spacing-lg; margin-top: $spacing-lg;
margin-bottom: $spacing-md; margin-bottom: $spacing-md;
@@ -890,7 +908,8 @@
margin-bottom: $spacing-md; margin-bottom: $spacing-md;
} }
ul, ol { ul,
ol {
margin-left: $spacing-lg; margin-left: $spacing-lg;
margin-bottom: $spacing-md; margin-bottom: $spacing-md;
} }
@@ -900,7 +919,8 @@
border-collapse: collapse; border-collapse: collapse;
margin-bottom: $spacing-md; margin-bottom: $spacing-md;
th, td { th,
td {
border: 1px solid $border-gray; border: 1px solid $border-gray;
padding: $spacing-sm; padding: $spacing-sm;
text-align: left; text-align: left;
@@ -947,3 +967,22 @@
padding: $spacing-md; padding: $spacing-md;
} }
} }
// Premium card wrapper for registration forms
.register-card-wrapper {
background: #FFFFFF;
border: 1px solid rgba(226, 232, 240, 0.8);
border-radius: 20px;
padding: 48px 40px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.03);
margin-top: 30px;
margin-bottom: 60px;
width: 100%;
@include respond-to('sm') {
padding: 24px 16px;
border-radius: 12px;
margin-top: 15px;
margin-bottom: 40px;
}
}
@@ -554,6 +554,48 @@ $service-card-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgb
justify-content: center; justify-content: center;
margin-top: 20px; margin-top: 20px;
} }
// 권한별 이용 가능 서비스 (TBD placeholder)
.signup-roles-tbd {
margin-top: 40px;
&__title {
font-size: 20px;
font-weight: 700;
color: #140064;
margin: 0 0 16px;
}
&__placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 48px 24px;
background: #f8f9fa;
border: 1px dashed #e3e8f0;
border-radius: 12px;
text-align: center;
}
&__badge {
display: inline-block;
padding: 4px 12px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
color: #0049B4;
background: #eff9fe;
border-radius: 20px;
}
&__desc {
margin: 0;
font-size: 15px;
color: #6e7781;
}
}
} }
// Responsive // Responsive
@@ -7,16 +7,14 @@
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
.signup-selection-page { .signup-selection-page {
min-height: calc(100vh - 380px); // Account for header and footer
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: #EDF9FE; // Light blue background from Figma (same as login) padding: 100px 20px;
padding: 30px 20px;
position: relative; position: relative;
border-radius: 12px; border-radius: 12px;
margin-top: 30px; margin-top: 60px;
margin-bottom: 30px; margin-bottom: 50px;
} }
.signup-selection-container { .signup-selection-container {
@@ -38,118 +36,225 @@
} }
} }
.signup-logo { .signup-header {
width: 90px; display: flex;
height: 90px; flex-direction: column;
margin-bottom: 15px; align-items: center;
margin-bottom: 40px;
img {
width: 100%; width: 100%;
height: 100%; }
object-fit: contain;
.signup-title-row {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 12px;
color: #000000;
.signup-title-icon {
color: #000000;
flex-shrink: 0;
}
.signup-title {
font-family: $font-family-primary;
font-size: 36px;
font-weight: 700;
color: #000000;
margin: 0;
letter-spacing: -0.5px;
} }
} }
.signup-message { .signup-message {
font-family: $font-family-primary; font-family: $font-family-primary;
font-size: 20px; font-size: 16px;
font-weight: 400; font-weight: 400;
color: #000000; color: #718096;
text-align: center; text-align: center;
margin-bottom: 20px; margin: 0;
line-height: 1; line-height: 1.5;
} }
.signup-buttons { .signup-cards {
width: 100%; width: 100%;
max-width: 504px; max-width: 760px;
display: flex;
flex-direction: row;
gap: 28px;
margin-bottom: 45px;
justify-content: center;
}
.signup-card-item {
flex: 1;
background: #FFFFFF;
border: 1px solid rgba(226, 232, 240, 0.8);
border-radius: 24px;
padding: 45px 30px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 20px;
margin-bottom: 30px;
}
.signup-btn {
width: 100%;
height: 80px;
display: flex;
align-items: center; align-items: center;
justify-content: center; text-align: center;
gap: 10px;
padding: 10px;
font-family: $font-family-primary;
font-size: 20px;
font-weight: 700;
color: #FFFFFF;
text-decoration: none; text-decoration: none;
border-radius: 12px; transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
color: #1A202C;
cursor: pointer; cursor: pointer;
transition: all 0.3s ease; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.03);
line-height: 1;
position: relative; position: relative;
overflow: hidden;
&:hover { // Background subtle glow effect on hover
transform: translateY(-2px); &::before {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: radial-gradient(circle at top right, rgba(255, 255, 255, 0.8), transparent 70%);
opacity: 0;
transition: opacity 0.4s ease;
pointer-events: none;
} }
&:active { .card-icon-wrapper {
transform: translateY(0); width: 76px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); height: 76px;
} border-radius: 22px; // Squircle style
.signup-btn-icon {
width: 26px;
height: 30px;
flex-shrink: 0;
}
span {
white-space: nowrap;
}
}
.signup-btn-individual {
background: #0049B4;
}
.signup-btn-organization {
background: #00A1D7;
}
.signup-navigation {
display: flex; display: flex;
justify-content: center;
align-items: center; align-items: center;
gap: 26px; justify-content: center;
flex-wrap: wrap; margin-bottom: 24px;
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.02);
}
.signup-nav-link { .card-text-wrapper {
display: flex;
flex-direction: column;
align-items: center;
}
.card-title {
font-family: $font-family-primary; font-family: $font-family-primary;
font-size: 16px; font-size: 22px;
font-weight: 400; font-weight: 800;
color: #000000; margin-top: 0;
text-decoration: none; margin-bottom: 12px;
transition: color 0.3s ease; line-height: 1.3;
line-height: 1; color: #1A202C;
transition: all 0.4s ease;
}
.card-desc {
font-family: $font-family-primary;
font-size: 14px;
color: #718096;
line-height: 1.6;
margin: 0;
max-width: 250px;
word-break: keep-all;
transition: color 0.4s ease;
}
&:hover { &:hover {
color: #0049B4; transform: translateY(-8px);
text-decoration: underline; background: #FFFFFF;
&::before {
opacity: 1;
} }
} }
.signup-nav-separator { &.individual-card {
color: #000000; .card-icon-wrapper {
font-size: 16px; background: linear-gradient(135deg, #F0F5FF 0%, #D8E5FF 100%);
line-height: 1; color: #0049B4;
}
&:hover {
border-color: #0049B4;
box-shadow: 0 20px 40px rgba(0, 73, 180, 0.12);
.card-title {
color: #0049B4;
}
.card-icon-wrapper {
background: linear-gradient(135deg, #0049B4 0%, #0066FF 100%);
color: #FFFFFF;
transform: scale(1.05) rotate(3deg);
box-shadow: 0 10px 20px rgba(0, 73, 180, 0.25);
}
}
}
&.organization-card {
.card-icon-wrapper {
background: linear-gradient(135deg, #EBF8FF 0%, #CAF0F8 100%);
color: #00A1D7;
}
&:hover {
border-color: #00A1D7;
box-shadow: 0 20px 40px rgba(0, 161, 215, 0.12);
.card-title {
color: #00A1D7;
}
.card-icon-wrapper {
background: linear-gradient(135deg, #00A1D7 0%, #33C2FF 100%);
color: #FFFFFF;
transform: scale(1.05) rotate(-3deg);
box-shadow: 0 10px 20px rgba(0, 161, 215, 0.25);
}
}
}
}
.signup-navigation-box {
display: flex;
align-items: center;
background: #EDF4FF;
border: 1px solid #0049B4;
border-radius: 8px;
overflow: hidden;
height: 42px;
max-width: 280px;
width: 100%;
margin: 0 auto;
.signup-nav-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-family: $font-family-primary;
font-size: 14px;
font-weight: 700;
color: #0049B4;
text-decoration: none;
height: 100%;
transition: all 0.2s ease;
&:hover {
background: #DCE7FF;
}
}
.signup-nav-divider {
width: 1px;
height: 100%;
background: #0049B4;
} }
} }
// Responsive adjustments - Figma Mobile Design (sm: 768px) // Responsive adjustments - Figma Mobile Design (sm: 768px)
@include respond-to('sm') { @include respond-to('sm') {
.signup-selection-page { .signup-selection-page {
// Figma: 전체 배경 #ebfbff, 높이
background: #ebfbff; background: #ebfbff;
min-height: calc(100vh - 44px); min-height: calc(100vh - 44px);
padding: 40px 20px; padding: 40px 20px;
@@ -163,73 +268,83 @@
padding: 0; padding: 0;
} }
.signup-selection-card { .signup-header {
padding: 0; margin-bottom: 30px;
.signup-title-row {
margin-bottom: 8px;
.signup-title-icon {
width: 24px;
height: 24px;
} }
.signup-logo { .signup-title {
// Figma: 64px × 64px font-size: 24px;
width: 64px; }
height: 64px; }
margin-bottom: 24px;
} }
.signup-message { .signup-message {
// Figma: 14px Regular, #212529
font-size: 14px; font-size: 14px;
font-weight: 400; font-weight: 400;
color: #212529; color: #212529;
margin-bottom: 80px; margin-bottom: 40px;
line-height: 26px; line-height: 22px;
} }
.signup-buttons { .signup-cards {
// Figma: 버튼 간격 24px
max-width: 335px; max-width: 335px;
gap: 24px; flex-direction: column;
margin-bottom: 80px; gap: 16px;
margin-bottom: 40px;
} }
.signup-btn { .signup-card-item {
// Figma: 335px × 44px, border-radius 8px padding: 24px 20px;
border-radius: 12px;
flex-direction: row;
text-align: left;
align-items: center;
gap: 16px;
.card-icon-wrapper {
width: 44px;
height: 44px; height: 44px;
font-size: 16px; margin-bottom: 0;
font-weight: 700; flex-shrink: 0;
border-radius: 8px;
padding: 8px 12px;
justify-content: center;
gap: 8px;
svg { svg {
width: 14px; width: 20px;
height: 16px; height: 20px;
flex-shrink: 0; }
} }
span { .card-text-wrapper {
text-align: center; align-items: flex-start;
}
.card-title {
font-size: 16px;
margin-bottom: 4px;
}
.card-desc {
font-size: 12px;
} }
&:hover { &:hover {
transform: none; transform: none;
box-shadow: none; box-shadow: none;
opacity: 0.9;
} }
} }
.signup-navigation { .signup-navigation-box {
// Figma: 14px Regular, #515961, gap 12px max-width: 260px;
gap: 12px; height: 38px;
.signup-nav-link { .signup-nav-btn {
font-size: 14px; font-size: 13px;
font-weight: 400;
color: #515961;
}
.signup-nav-separator {
font-size: 14px;
color: #515961;
} }
} }
} }
@@ -48,7 +48,7 @@
<!-- Header --> <!-- Header -->
<div class="api-market-header"> <div class="api-market-header">
<div class="api-market-title"> <div class="api-market-title">
<h1>서비스</h1> <h1 th:text="${apiSpecInfo.apiGroupName}">그룹명</h1>
<h2 th:text="${apiSpecInfo.apiName}">API 이름</h2> <h2 th:text="${apiSpecInfo.apiName}">API 이름</h2>
</div> </div>
</div> </div>
@@ -69,8 +69,9 @@
<div class="tab-content" th:classappend="${activeTab == 'testbed'} ? '' : 'active'" id="api-info-tab"> <div class="tab-content" th:classappend="${activeTab == 'testbed'} ? '' : 'active'" id="api-info-tab">
<!-- API Overview Card (Merged with Additional Information) --> <!-- API Overview Card (Merged with Additional Information) -->
<div class="api-overview-card"> <div class="api-overview-card">
<div class="org-section-header org-section-header--agreement"> <div class="org-section-header org-section-header--agreement api-basic-info-header">
<h3>기본 정보</h3> <h3>기본 정보</h3>
<button type="button" class="btn-action-primary md api-apply-btn">API 사용 신청</button>
</div> </div>
<div class="api-overview-header"> <div class="api-overview-header">
<div class="api-method-badge" th:text="${apiSpecInfo.apiMethod}">GET</div> <div class="api-method-badge" th:text="${apiSpecInfo.apiMethod}">GET</div>
@@ -142,6 +143,11 @@
</div> </div>
</div> </div>
</div> </div>
<!-- 하단 중앙 API 사용 신청 -->
<div class="api-apply-footer">
<button type="button" class="btn-action-primary api-apply-btn">API 사용 신청</button>
</div>
</div> </div>
<!-- Testbed Tab Content --> <!-- Testbed Tab Content -->
@@ -275,6 +281,8 @@
// 활성 탭은 서버가 결정하고, 테스트베드 탭이 활성이면서 인증된 경우에만 // 활성 탭은 서버가 결정하고, 테스트베드 탭이 활성이면서 인증된 경우에만
// Swagger UI를 초기화한다(실제 초기화는 하단에서 실행 — const 선언 이후). // Swagger UI를 초기화한다(실제 초기화는 하단에서 실행 — const 선언 이후).
const currentApiId = /*[[${apiSpecInfo.apiId}]]*/ 'default'; const currentApiId = /*[[${apiSpecInfo.apiId}]]*/ 'default';
// 현재 API 의 응답유형(sample/mock/gw) — mock 이면 샘플 Secret + mock 토큰으로 인증 진행
const CURRENT_RESPONSE_TYPE = /*[[${apiSpecInfo.responseType}]]*/ 'sample';
const activeTab = /*[[${activeTab}]]*/ 'api-info'; const activeTab = /*[[${activeTab}]]*/ 'api-info';
const isAuthenticated = /*[[${authenticated}]]*/ false; const isAuthenticated = /*[[${authenticated}]]*/ false;
const testbedActive = (activeTab === 'testbed' && isAuthenticated); const testbedActive = (activeTab === 'testbed' && isAuthenticated);
@@ -364,6 +372,8 @@
onComplete: function() { onComplete: function() {
// 단일 API 페이지 — 상시 요청 스니펫 패널 마운트 // 단일 API 페이지 — 상시 요청 스니펫 패널 마운트
setTimeout(function() { try { window.DjbSwaggerSnippetPanel.mount({ rootSel: '#swagger-ui' }); } catch (e) { console.error(e); } }, 50); setTimeout(function() { try { window.DjbSwaggerSnippetPanel.mount({ rootSel: '#swagger-ui' }); } catch (e) { console.error(e); } }, 50);
// "앱 선택" 패널을 Swagger 서버 영역(.scheme-container) 안으로 이관
setTimeout(function() { try { relocateAppPanel(); } catch (e) { console.error(e); } }, 50);
}, },
showMutatedRequest: false, showMutatedRequest: false,
validatorUrl: '', validatorUrl: '',
@@ -411,10 +421,88 @@
}); });
} }
// ===== API 사용 신청: 회원 구분별 분기 (API 신청 절차) =====
// 비회원/개인회원 → 법인회원 안내 팝업 → 회원가입 안내 이동
// 법인이용자 → 클라이언트 현황: 신규(0건)→신규 클라이언트 / 기존(1건)→클라이언트 수정의 API 선택 직행 / 기존(1건 초과)→클라이언트 관리
function requestApiUse() {
fetch(/*[[@{/djb/testbed/auth/context}]]*/ '/djb/testbed/auth/context')
.then(function (r) { return r.json(); })
.then(function (ctx) {
const reason = ctx && ctx.reason;
if (reason === 'ANONYMOUS' || reason === 'INDIVIDUAL_USER') {
const go = function () { window.location.href = /*[[@{/service/guide}]]*/ '/service/guide'; };
// custom-popups.js 는 `const customPopups`(전역 렉시컬) 로 노출 — window 프로퍼티 아님.
if (typeof customPopups !== 'undefined' && customPopups.showAlert) {
customPopups.showAlert(
'API 사용 신청은 법인회원(법인관리자·법인개발자)만 이용할 수 있습니다.<br>회원 가입 안내 페이지로 이동합니다.',
go
);
} else { go(); }
return;
}
// 법인이용자: 보유 클라이언트 수로 신규/기존 분기 (이동 페이지에서 Toast 안내)
const creds = (ctx && ctx.credentials) || [];
if (creds.length === 1) {
// 기존 1건 → 해당 클라이언트 수정 플로우의 API 선택(2단계) 직행 + Toast
const modifyBase = /*[[@{/clients/modify/step1}]]*/ '/clients/modify/step1';
window.location.href = modifyBase
+ '?clientId=' + encodeURIComponent(creds[0].clientId)
+ '&goto=apis&apiApplyToast=modify';
return;
}
if (creds.length > 1) {
// 기존 여러 건 → 클라이언트 목록(선택) + Toast
window.location.href = /*[[@{/clients(apiApplyToast='existing')}]]*/ '/clients?apiApplyToast=existing';
return;
}
// 신규(클라이언트 0) → 생성 필요 안내 확인 후 이동
const goNew = function () {
window.location.href = /*[[@{/clients/register/step1(clear=true,apiApplyToast='new')}]]*/ '/clients/register/step1?clear=true&apiApplyToast=new';
};
if (typeof customPopups !== 'undefined' && customPopups.showConfirm) {
customPopups.showConfirm(
'API 를 이용하려면 신규 클라이언트 생성이 필요합니다.<br>클라이언트 생성 페이지로 이동하시겠습니까?',
function (ok) { if (ok) goNew(); }
);
} else { goNew(); }
})
.catch(function (e) {
console.error('API 사용 신청 컨텍스트 조회 실패', e);
window.location.href = /*[[@{/clients}]]*/ '/clients';
});
}
Array.prototype.forEach.call(document.querySelectorAll('.api-apply-btn'), function (btn) {
btn.addEventListener('click', requestApiUse);
});
// ===== DJPGPT0001: 앱 인증 정보 자동 주입 ===== // ===== DJPGPT0001: 앱 인증 정보 자동 주입 =====
const DEFAULT_TOKEN_API_ID = 'default-token-api-spec'; const DEFAULT_TOKEN_API_ID = 'default-token-api-spec';
const appsSelect = document.getElementById('apps'); const appsSelect = document.getElementById('apps');
// "앱 선택" 패널(#appsWrap)을 Swagger 서버 영역(.scheme-container) 안으로 이관.
// 서버 셀렉트박스는 CSS 로 숨기고, 그 자리에 앱 선택을 배치한다.
// SwaggerUI 가 scheme-container 를 재렌더하면 노드가 이탈할 수 있어 observer 로 재고정.
function relocateAppPanel() {
const panel = document.getElementById('appsWrap');
if (!panel) return;
const scheme = document.querySelector('#swagger-ui .scheme-container');
if (!scheme) return;
const host = scheme.querySelector('.schemes') || scheme;
if (panel.parentNode !== host) host.appendChild(panel);
}
// scheme-container 재렌더 대비: #swagger-ui 변경 감지 시 재이관(멱등)
(function watchAppPanelRelocation() {
const root = document.getElementById('swagger-ui');
if (!root) return;
const obs = new MutationObserver(function () {
const panel = document.getElementById('appsWrap');
const host = document.querySelector('#swagger-ui .scheme-container .schemes')
|| document.querySelector('#swagger-ui .scheme-container');
if (panel && host && panel.parentNode !== host) host.appendChild(panel);
});
obs.observe(root, { childList: true, subtree: true });
})();
function eligibilityMessage(reason) { function eligibilityMessage(reason) {
switch (reason) { switch (reason) {
case 'ANONYMOUS': return '로그인 후 본인 앱의 인증 정보를 사용할 수 있습니다.'; case 'ANONYMOUS': return '로그인 후 본인 앱의 인증 정보를 사용할 수 있습니다.';
@@ -438,43 +526,136 @@
} }
appsSelect.disabled = false; appsSelect.disabled = false;
if (notice) notice.style.display = 'none'; if (notice) notice.style.display = 'none';
// 표시용 client id 축약: 앞 5글자 + "..." (value 는 full 유지 → 인증에 사용)
const abbrevId = function (id) {
id = id || '';
return id.length > 5 ? (id.slice(0, 5) + '...') : id;
};
(ctx.credentials || []).forEach(function (c) { (ctx.credentials || []).forEach(function (c) {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = c.clientId; opt.value = c.clientId;
opt.textContent = c.clientName ? (c.clientName + ' (' + c.clientId + ')') : c.clientId; opt.textContent = c.clientName
? (c.clientName + ' (' + abbrevId(c.clientId) + ')')
: abbrevId(c.clientId);
appsSelect.appendChild(opt); appsSelect.appendChild(opt);
}); });
}) })
.catch(function (e) { console.error('테스트베드 컨텍스트 로드 실패', e); }); .catch(function (e) { console.error('테스트베드 컨텍스트 로드 실패', e); });
} }
function fetchOAuthToken(clientId, clientSecret, gw) { // 인증 실패/취소 시 앱 선택을 미선택 상태로 되돌린다 (mock 포함 — 선택 유지 시 인증된 것처럼 보이는 오해 방지)
function resetAppSelection() {
if (appsSelect) appsSelect.value = '';
}
// 테스트베드 오류 안내 — modal dialog (custom-popups)
function showTestbedError(msg) {
if (typeof customPopups !== 'undefined' && customPopups.showAlert) {
customPopups.showAlert(msg);
} else {
alert(String(msg).replace(/<br\s*\/?>/gi, '\n'));
}
}
function fetchOAuthToken(clientId, clientSecret, tokenUrl) {
const body = 'grant_type=client_credentials' const body = 'grant_type=client_credentials'
+ '&client_id=' + encodeURIComponent(clientId) + '&client_id=' + encodeURIComponent(clientId)
+ '&client_secret=' + encodeURIComponent(clientSecret) + '&client_secret=' + encodeURIComponent(clientSecret)
+ '&scope=api'; + '&scope=api';
// 토큰 발급 프록시 여부(djb.gateway.token-use-proxy). false 면 토큰 URL 로 브라우저 직접 호출. // 토큰 발급 프록시 여부(djb.gateway.token-use-proxy). false 면 토큰 URL 로 브라우저 직접 호출.
var direct = (window.__djbTokenUseProxy === false); var direct = (window.__djbTokenUseProxy === false);
var url = direct ? gw.tokenUrl : window.location.origin + (/*[[@{/api/call-api}]]*/ '/api/call-api'); var url = direct ? tokenUrl : window.location.origin + (/*[[@{/api/call-api}]]*/ '/api/call-api');
var headers = { 'Content-Type': 'application/x-www-form-urlencoded' }; var headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
if (!direct) { if (!direct) {
headers['original-url'] = gw.tokenUrl; headers['original-url'] = tokenUrl;
headers['X-XSRF-TOKEN'] = /*[[${_csrf.token}]]*/ ''; headers['X-XSRF-TOKEN'] = /*[[${_csrf.token}]]*/ '';
} }
return fetch(url, { return fetch(url, {
method: 'POST', method: 'POST',
headers: headers, headers: headers,
body: body body: body
}).then(function (r) { return r.ok ? r.json() : null; }) }).then(function (r) {
.then(function (data) { return data ? data.access_token : null; }); // 실패 시에도 프록시(ApiTesterFilter)가 {error, detail} JSON 을 반환 — 메시지로 살려 UI 에 표시
return r.json().catch(function () { return null; }).then(function (data) {
if (!r.ok || !data || !data.access_token) {
var msg = (data && (data.error || data.error_description))
|| ('토큰 발급 요청 실패 (HTTP ' + r.status + ')');
var detail = (data && data.detail) ? '<br><span style="color:#888;font-size:13px;">' + data.detail + '</span>' : '';
throw new Error('인증 토큰 발급에 실패했습니다.<br>' + msg + detail);
}
return data.access_token;
});
});
}
// 현재 API 가 mock 응답유형인지 (전역 게이트웨이 설정과 무관하게 API 별 판단)
function isMockApi() {
return (CURRENT_RESPONSE_TYPE || '').toLowerCase() === 'mock';
} }
function authorizeApp(secret) { function authorizeApp(secret) {
const gw = window.__djbGateway || {}; const gw = window.__djbGateway || {};
if (!window.ui) return; if (!window.ui) return;
// 응답유형 mock: 실 Secret 검증 없이 샘플 SecretKey + mock 토큰으로 인증 진행 — toast 안내
if (isMockApi()) {
if (typeof djbToast === 'function') {
djbToast('Mock 서버로 호출되므로 별도 Secret Key 로 인증 절차를 진행합니다.', { type: 'info', duration: 5000 });
}
proceedAuthorize(secret, secret.clientSecret || 'mock-secret', gw);
return;
}
// gw 응답유형 + Client Secret 이미 1회 노출·삭제 → 사용자가 보관 중인 Secret 직접 입력
if (!secret.clientSecret) {
promptClientSecret(secret, gw);
return;
}
proceedAuthorize(secret, secret.clientSecret, gw);
}
// Client Secret 수동 입력 팝업 (포탈에는 이미 삭제된 경우)
function promptClientSecret(secret, gw) {
if (typeof customPopups === 'undefined' || !customPopups.showPasswordInput) {
resetAppSelection();
showTestbedError('선택한 앱의 Client Secret 이 포탈에 존재하지 않습니다.<br>인증키를 새로 신청해 주세요.');
return;
}
customPopups.showPasswordInput({
title: 'Client Secret 입력',
message: 'Client Secret 은 1회 노출 후 삭제되어 포탈에 없습니다.<br>'
+ '보관 중인 값을 입력해 주세요.<br><br>'
+ '입력한 값은 저장되지 않고 인증에만 사용됩니다.',
placeholder: 'Client Secret 을 입력하세요',
onConfirm: function (value) {
customPopups.hidePasswordInput();
proceedAuthorize(secret, value, gw);
},
onCancel: function () {
// 취소 — 인증 미완료이므로 앱 선택도 되돌린다
resetAppSelection();
}
});
}
// authorize 반영 직후, 이미 열려 있는 "요청 스니펫" 상시 패널을 재렌더해 인증 헤더를 즉시 반영.
// (패널은 탭 클릭/Execute/입력 변경에만 자체 갱신하므로 앱 선택 시점엔 명시 호출이 필요)
function refreshSnippetPanel() {
if (window.DjbSwaggerSnippetPanel && typeof DjbSwaggerSnippetPanel.render === 'function') {
// authorize 상태가 Swagger store 에 반영된 뒤 읽도록 한 틱 늦춘다
setTimeout(function () { DjbSwaggerSnippetPanel.render(); }, 50);
}
}
// 확보된 Client Secret 으로 인증 주입 (OAUTH: 토큰 발급 / API_KEY: 헤더 주입)
// 토큰 URL: mock API → 포탈 mock 토큰 경로(ApiTesterFilter 가 즉시 발급), gw API → 실 GW 토큰 엔드포인트
function proceedAuthorize(secret, clientSecret, gw) {
var tokenUrl = isMockApi()
? window.location.origin + '/api/v1/oauth/token'
: gw.tokenUrl;
if (secret.authType === 'OAUTH') { if (secret.authType === 'OAUTH') {
fetchOAuthToken(secret.clientId, secret.clientSecret, gw).then(function (token) { fetchOAuthToken(secret.clientId, clientSecret, tokenUrl).then(function (token) {
if (!token) return;
window.ui.authActions.authorize({ window.ui.authActions.authorize({
djbOAuth: { djbOAuth: {
name: 'djbOAuth', name: 'djbOAuth',
@@ -482,15 +663,21 @@
value: 'Bearer ' + token value: 'Bearer ' + token
} }
}); });
refreshSnippetPanel();
}).catch(function (e) {
console.error('토큰 발급 실패', e);
resetAppSelection();
showTestbedError(e && e.message ? e.message : '인증 토큰 발급 중 오류가 발생했습니다.');
}); });
} else if (secret.authType === 'API_KEY') { } else if (secret.authType === 'API_KEY') {
window.ui.authActions.authorize({ window.ui.authActions.authorize({
djbApiKey: { djbApiKey: {
name: 'djbApiKey', name: 'djbApiKey',
schema: { type: 'apiKey', in: 'header', name: gw.apiKeyHeader }, schema: { type: 'apiKey', in: 'header', name: gw.apiKeyHeader },
value: secret.clientSecret value: clientSecret
} }
}); });
refreshSnippetPanel();
} }
} }
@@ -499,9 +686,18 @@
if (!currentApiId || currentApiId === 'default' || currentApiId === DEFAULT_TOKEN_API_ID) return; if (!currentApiId || currentApiId === 'default' || currentApiId === DEFAULT_TOKEN_API_ID) return;
fetch((/*[[@{/djb/testbed/auth/credentials/}]]*/ '/djb/testbed/auth/credentials/') fetch((/*[[@{/djb/testbed/auth/credentials/}]]*/ '/djb/testbed/auth/credentials/')
+ encodeURIComponent(clientId) + '/secret?apiId=' + encodeURIComponent(currentApiId)) + encodeURIComponent(clientId) + '/secret?apiId=' + encodeURIComponent(currentApiId))
.then(function (r) { return r.ok ? r.json() : null; }) .then(function (r) {
if (!r.ok) {
throw new Error('앱 인증정보 조회에 실패했습니다. (HTTP ' + r.status + ')');
}
return r.json();
})
.then(function (secret) { if (secret) authorizeApp(secret); }) .then(function (secret) { if (secret) authorizeApp(secret); })
.catch(function (e) { console.error('앱 인증정보 주입 실패', e); }); .catch(function (e) {
console.error('앱 인증정보 주입 실패', e);
resetAppSelection();
showTestbedError(e && e.message ? e.message : '앱 인증정보 주입 중 오류가 발생했습니다.');
});
} }
// 테스트베드 탭 활성 + 인증 상태일 때만 Swagger UI/앱 인증 컨텍스트 초기화 // 테스트베드 탭 활성 + 인증 상태일 때만 Swagger UI/앱 인증 컨텍스트 초기화
@@ -10,7 +10,8 @@
</div> </div>
</section> </section>
<section layout:fragment="contentFragment"> <section layout:fragment="contentFragment">
<div class="service-main"> <div class="service-main container" style="padding-top: 70px; padding-bottom:100px;">
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('profile')}"></th:block>
<div class="app-management-content"> <div class="app-management-content">
<div class="password-change-wrapper"> <div class="password-change-wrapper">
<h2 class="page-outer-title">본인 확인</h2> <h2 class="page-outer-title">본인 확인</h2>
@@ -1,12 +1,14 @@
<!doctype html> <!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" <html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kbank_base_layout}"> xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kbank_base_layout}">
<body> <body>
<section layout:fragment="contentFragment" class="content"> <section layout:fragment="contentFragment" class="content">
<div class="content_wrap"> <div class="content_wrap">
<div class="account-recovery-container"> <div class="account-recovery-container">
<div class="account-recovery-card"> <div class="account-recovery-card">
<!-- 탭 메뉴 -->
<!-- 탭 메뉴 (pill 스타일) -->
<div class="account-recovery-tabs"> <div class="account-recovery-tabs">
<a href="#tab1" class="tab-link active" data-tab="tab1">아이디 찾기</a> <a href="#tab1" class="tab-link active" data-tab="tab1">아이디 찾기</a>
<a href="#tab2" class="tab-link" data-tab="tab2">비밀번호 초기화</a> <a href="#tab2" class="tab-link" data-tab="tab2">비밀번호 초기화</a>
@@ -14,19 +16,18 @@
<!-- 아이디 찾기 폼 --> <!-- 아이디 찾기 폼 -->
<div id="tab1" class="account-recovery-form"> <div id="tab1" class="account-recovery-form">
<form id="findIdForm" role="form" name="findIdForm" th:action="@{/find_id}" method="post" data-form-type="findId"> <form id="findIdForm" role="form" name="findIdForm" th:action="@{/find_id}" method="post"
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/> data-form-type="findId">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<input type="hidden" id="findId-phoneNumber" name="phoneNumber"> <input type="hidden" id="findId-phoneNumber" name="phoneNumber">
<!-- 성명 입력 --> <!-- 성명 입력 -->
<div class="form-group"> <div class="form-group">
<label class="form-label">성명</label> <input type="text" name="name" class="form-input" placeholder="성명" required>
<input type="text" name="name" class="form-input" placeholder="이름" required>
</div> </div>
<!-- 휴대폰 번호 입력 --> <!-- 휴대폰 번호 입력 -->
<div class="form-group"> <div class="form-group">
<label class="form-label">휴대폰 번호</label>
<div class="phone-input-group"> <div class="phone-input-group">
<select class="form-select phone-prefix select_mobile_prefix"> <select class="form-select phone-prefix select_mobile_prefix">
<option>선택</option> <option>선택</option>
@@ -46,7 +47,6 @@
<!-- 인증번호 입력 --> <!-- 인증번호 입력 -->
<div class="form-group auth-number-container" id="findId-authNumberContainer" style="display: none;"> <div class="form-group auth-number-container" id="findId-authNumberContainer" style="display: none;">
<label class="form-label">인증번호 입력</label>
<div class="auth-input-group"> <div class="auth-input-group">
<input type="number" class="form-input auth-input auth-number-input" placeholder="SMS 인증번호" required> <input type="number" class="form-input auth-input auth-number-input" placeholder="SMS 인증번호" required>
<span class="auth-timer" id="findId-certify_time">03:00</span> <span class="auth-timer" id="findId-certify_time">03:00</span>
@@ -63,41 +63,38 @@
</div> </div>
<!-- 비밀번호 초기화 폼 --> <!-- 비밀번호 초기화 폼 -->
<div id="tab2" class="account-recovery-form"> <div id="tab2" class="account-recovery-form" style="display: none;">
<form id="resetPasswordForm" role="form" name="resetPasswordForm" th:action="@{/reset_password}" method="post" data-form-type="resetPassword"> <form id="resetPasswordForm" role="form" name="resetPasswordForm" th:action="@{/reset_password}"
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/> method="post" data-form-type="resetPassword">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<input type="hidden" id="resetPassword-phoneNumber" name="phoneNumber"> <input type="hidden" id="resetPassword-phoneNumber" name="phoneNumber">
<!-- 인증 방식 선택 --> <!-- 인증 방식 선택 (pill 버튼 스타일) -->
<div class="form-group"> <div class="form-group reset-method-group">
<label class="form-label">초기화 방법</label> <div class="reset-method-tabs">
<div style="display: flex; gap: 20px; align-items: center;"> <label class="reset-method-tab">
<label style="display: flex; align-items: center; gap: 6px; font-size: 14px; margin: 0;">
<input type="radio" id="radio_hp" name="resetMethod" value="hp" checked> <input type="radio" id="radio_hp" name="resetMethod" value="hp" checked>
휴대폰 번호 <span>휴대폰 번호</span>
</label> </label>
<label style="display: flex; align-items: center; gap: 6px; font-size: 14px; margin: 0;"> <label class="reset-method-tab">
<input type="radio" id="radio_email" name="resetMethod" value="email_id"> <input type="radio" id="radio_email" name="resetMethod" value="email_id">
이메일 아이디 <span>이메일 아이디</span>
</label> </label>
</div> </div>
</div> </div>
<!-- 성명 입력 --> <!-- 성명 입력 -->
<div class="form-group"> <div class="form-group">
<label class="form-label">성명</label>
<input type="text" name="name" class="form-input" placeholder="성명" required> <input type="text" name="name" class="form-input" placeholder="성명" required>
</div> </div>
<!-- 아이디 입력 (이메일) --> <!-- 아이디 입력 (이메일) -->
<div class="form-group" id="emailInputContainer" style="display: none;"> <div class="form-group" id="emailInputContainer" style="display: none;">
<label class="form-label">이메일 아이디</label>
<input type="text" name="emailId" class="form-input" placeholder="이메일 아이디"> <input type="text" name="emailId" class="form-input" placeholder="이메일 아이디">
</div> </div>
<!-- 휴대폰 번호 입력 --> <!-- 휴대폰 번호 입력 -->
<div class="form-group" id="phoneInputContainer"> <div class="form-group" id="phoneInputContainer">
<label class="form-label">휴대폰 번호</label>
<div class="phone-input-group"> <div class="phone-input-group">
<select class="form-select phone-prefix select_mobile_prefix"> <select class="form-select phone-prefix select_mobile_prefix">
<option>선택</option> <option>선택</option>
@@ -117,8 +114,7 @@
<!-- 인증번호 입력 --> <!-- 인증번호 입력 -->
<div class="form-group auth-number-container" id="authNumberContainer" style="display: none;"> <div class="form-group auth-number-container" id="authNumberContainer" style="display: none;">
<label class="form-label">인증번호 입력</label> <div class="auth-input-group mg-y">
<div class="auth-input-group">
<input type="number" class="form-input auth-input auth-number-input" placeholder="SMS 인증번호" required> <input type="number" class="form-input auth-input auth-number-input" placeholder="SMS 인증번호" required>
<span class="auth-timer" id="certify_time">03:00</span> <span class="auth-timer" id="certify_time">03:00</span>
<button type="button" class="auth-verify-button btn_verify_auth">확인</button> <button type="button" class="auth-verify-button btn_verify_auth">확인</button>
@@ -132,17 +128,18 @@
</div> </div>
</form> </form>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
</body> </body>
<th:block layout:fragment="contentScript"> <th:block layout:fragment="contentScript">
<script th:inline="javascript"> <script th:inline="javascript">
$(document).ready(function() { $(document).ready(function () {
// 탭 전환 기능 // 탭 전환 기능
$('.tab-link').on('click', function(e) { $('.tab-link').on('click', function (e) {
e.preventDefault(); e.preventDefault();
const tabId = $(this).data('tab'); const tabId = $(this).data('tab');
@@ -158,7 +155,7 @@
}); });
// 초기화 방법 선택에 따라 폼 표시 변경 // 초기화 방법 선택에 따라 폼 표시 변경
$('input[name="resetMethod"]').change(function() { $('input[name="resetMethod"]').change(function () {
if (this.value === 'hp') { if (this.value === 'hp') {
$('#phoneInputContainer').show(); $('#phoneInputContainer').show();
$('#emailInputContainer').hide(); $('#emailInputContainer').hide();
@@ -169,7 +166,7 @@
}); });
// 취소 버튼 클릭 시 로그인 페이지로 이동 // 취소 버튼 클릭 시 로그인 페이지로 이동
$('.btn_cancel').on('click', function() { $('.btn_cancel').on('click', function () {
location.href = '/login'; location.href = '/login';
}); });
@@ -189,7 +186,7 @@
} }
// 인증번호 요청 버튼 이벤트 리스너 // 인증번호 요청 버튼 이벤트 리스너
$(".btn_auth").on("click", function(e) { $(".btn_auth").on("click", function (e) {
e.preventDefault(); e.preventDefault();
let form = $(this).closest("form"); let form = $(this).closest("form");
let mobileNumber = combineMobileNumber(form); let mobileNumber = combineMobileNumber(form);
@@ -201,7 +198,7 @@
}); });
// 인증번호 확인 버튼 이벤트 리스너 // 인증번호 확인 버튼 이벤트 리스너
$(".btn_verify_auth").on("click", function(e) { $(".btn_verify_auth").on("click", function (e) {
e.preventDefault(); e.preventDefault();
let form = $(this).closest("form"); let form = $(this).closest("form");
let authNumberInput = form.find(".auth-number-input"); let authNumberInput = form.find(".auth-number-input");
@@ -216,4 +213,5 @@
}); });
</script> </script>
</th:block> </th:block>
</html> </html>
@@ -1,20 +1,21 @@
<!doctype html> <!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" <html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}"> xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
<body> <body>
<section layout:fragment="title"> <section layout:fragment="title">
<div class="page-title-banner"> <div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image"> <img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>아이디 찾기</h1> <h1>아이디 찾기</h1>
</div> </div>
</section> </section>
<th:block layout:fragment="contentFragment"> <th:block layout:fragment="contentFragment">
<div class="account-recovery-page"> <div class="account-recovery-page">
<div class="account-recovery-container"> <div class="account-recovery-container">
<div class="account-recovery-card"> <div class="account-recovery-card">
<!-- Tab Navigation --> <!-- Tab Navigation -->
<div class="account-recovery-tabs"> <div class="account-recovery-tabs">
<a href="#" class="tab-link active">아이디찾기</a> <a href="#" class="tab-link active">아이디 찾기</a>
<a th:href="@{/account_recovery(tab='resetPassword')}" class="tab-link">비밀번호 초기화</a> <a th:href="@{/account_recovery(tab='resetPassword')}" class="tab-link">비밀번호 초기화</a>
</div> </div>
@@ -22,13 +23,15 @@
<div id="alertContainer"></div> <div id="alertContainer"></div>
<!-- Account Recovery Form --> <!-- Account Recovery Form -->
<form id="accountForm" role="form" name="accountForm" th:action="@{/find_id}" method="post" class="account-recovery-form"> <form id="accountForm" role="form" name="accountForm" th:action="@{/find_id}" method="post"
class="account-recovery-form">
<input type="hidden" id="mobileNumber" name="mobileNumber" th:value="${mobileNumber}"> <input type="hidden" id="mobileNumber" name="mobileNumber" th:value="${mobileNumber}">
<!-- Name Field --> <!-- Name Field -->
<div class="form-group"> <div class="form-group">
<label for="userName" class="form-label">성명<span class="required-badge">필수</span></label> <label for="userName" class="form-label">성명<span class="required-badge">필수</span></label>
<input type="text" id="userName" name="userName" th:value="${userName}" class="form-input" placeholder="성명" required> <input type="text" id="userName" name="userName" th:value="${userName}" class="form-input"
placeholder="성명" required>
</div> </div>
<!-- Phone Number Fields --> <!-- Phone Number Fields -->
@@ -45,27 +48,13 @@
<option value="018">018</option> <option value="018">018</option>
</select> </select>
<span class="separator">-</span> <span class="separator">-</span>
<input type="tel" <input type="tel" id="phoneMiddle" name="phoneMiddle" maxlength="4" class="form-input phone-number"
id="phoneMiddle" placeholder="1234" pattern="[0-9]*" inputmode="numeric" required>
name="phoneMiddle"
maxlength="4"
class="form-input phone-number"
placeholder="1234"
pattern="[0-9]*"
inputmode="numeric"
required>
<span class="separator">-</span> <span class="separator">-</span>
<input type="tel" <input type="tel" id="phoneLast" name="phoneLast" maxlength="4" class="form-input phone-number"
id="phoneLast" placeholder="1234" pattern="[0-9]*" inputmode="numeric" required>
name="phoneLast"
maxlength="4"
class="form-input phone-number"
placeholder="1234"
pattern="[0-9]*"
inputmode="numeric"
required>
</div> </div>
<button type="button" class="btn org-btn-check btn_auth" id="requestAuthButton"> <button type="button" class="org-btn-check btn_auth" id="requestAuthButton">
인증번호 받기 인증번호 받기
</button> </button>
</div> </div>
@@ -74,16 +63,9 @@
<!-- Auth Number Input --> <!-- Auth Number Input -->
<div class="form-group auth-number-group" id="authNumberGroup" style="display: none;"> <div class="form-group auth-number-group" id="authNumberGroup" style="display: none;">
<label for="authNumber" class="form-label">인증번호 입력<span class="required-badge">필수</span></label> <label for="authNumber" class="form-label">인증번호 입력<span class="required-badge">필수</span></label>
<div class="auth-input-group"> <div class="auth-input-group mg-y">
<input type="text" <input type="text" id="authNumber" name="authNumber" maxlength="6" class="form-input auth-input"
id="authNumber" placeholder="인증번호 6자리" pattern="[0-9]*" inputmode="numeric" required>
name="authNumber"
maxlength="6"
class="form-input auth-input"
placeholder="인증번호 6자리"
pattern="[0-9]*"
inputmode="numeric"
required>
<span class="auth-timer" id="authTimer">03:00</span> <span class="auth-timer" id="authTimer">03:00</span>
</div> </div>
<button type="button" class="auth-verify-button" id="verifyAuthButton"> <button type="button" class="auth-verify-button" id="verifyAuthButton">
@@ -105,12 +87,12 @@
</div> </div>
</div> </div>
</div> </div>
</th:block> </th:block>
</body> </body>
<th:block layout:fragment="contentScript"> <th:block layout:fragment="contentScript">
<script th:inline="javascript"> <script th:inline="javascript">
$(document).ready(function() { $(document).ready(function () {
const errorMsg = [[${error}]]; const errorMsg = [[${ error }]];
if (errorMsg) { if (errorMsg) {
customPopups.showAlert(errorMsg, 'error'); customPopups.showAlert(errorMsg, 'error');
} }
@@ -167,7 +149,7 @@
} }
// 인증번호 요청 버튼 // 인증번호 요청 버튼
$('#requestAuthButton').on('click', function() { $('#requestAuthButton').on('click', function () {
const mobileNumber = combineMobileNumber(); const mobileNumber = combineMobileNumber();
if (!mobileNumber) { if (!mobileNumber) {
@@ -185,7 +167,7 @@
mobileNumber: mobileNumber, mobileNumber: mobileNumber,
_csrf: $('input[name="_csrf"]').val() _csrf: $('input[name="_csrf"]').val()
}, },
success: function(response) { success: function (response) {
$('#loadingOverlay').fadeOut(200); $('#loadingOverlay').fadeOut(200);
if (response.valid) { if (response.valid) {
@@ -210,7 +192,7 @@
customPopups.showAlert(response.message || '인증번호 발송에 실패했습니다.', 'error'); customPopups.showAlert(response.message || '인증번호 발송에 실패했습니다.', 'error');
} }
}, },
error: function() { error: function () {
$('#loadingOverlay').fadeOut(200); $('#loadingOverlay').fadeOut(200);
customPopups.showAlert('처리 중 오류가 발생했습니다. 다시 시도해주세요.', 'error'); customPopups.showAlert('처리 중 오류가 발생했습니다. 다시 시도해주세요.', 'error');
} }
@@ -218,7 +200,7 @@
}); });
// 인증번호 확인 버튼 // 인증번호 확인 버튼
$('#verifyAuthButton').on('click', function() { $('#verifyAuthButton').on('click', function () {
if (!isAuthNumberRequested) { if (!isAuthNumberRequested) {
customPopups.showAlert('휴대폰 번호 인증을 먼저 진행해주세요.', 'error'); customPopups.showAlert('휴대폰 번호 인증을 먼저 진행해주세요.', 'error');
return; return;
@@ -242,7 +224,7 @@
authNumber: authNumber, authNumber: authNumber,
_csrf: $('input[name="_csrf"]').val() _csrf: $('input[name="_csrf"]').val()
}, },
success: function(response) { success: function (response) {
$('#loadingOverlay').fadeOut(200); $('#loadingOverlay').fadeOut(200);
if (response.valid) { if (response.valid) {
@@ -260,7 +242,7 @@
customPopups.showAlert(response.message || '인증번호가 일치하지 않습니다.', 'error'); customPopups.showAlert(response.message || '인증번호가 일치하지 않습니다.', 'error');
} }
}, },
error: function() { error: function () {
$('#loadingOverlay').fadeOut(200); $('#loadingOverlay').fadeOut(200);
customPopups.showAlert('처리 중 오류가 발생했습니다. 다시 시도해주세요.', 'error'); customPopups.showAlert('처리 중 오류가 발생했습니다. 다시 시도해주세요.', 'error');
} }
@@ -268,7 +250,7 @@
}); });
// 폼 제출 // 폼 제출
$('#accountForm').on('submit', function(e) { $('#accountForm').on('submit', function (e) {
e.preventDefault(); e.preventDefault();
const userName = $('#userName').val().trim(); const userName = $('#userName').val().trim();
@@ -298,7 +280,7 @@
}); });
// 취소 버튼 // 취소 버튼
$('#cancelButton').on('click', function() { $('#cancelButton').on('click', function () {
window.location.href = '/login'; window.location.href = '/login';
}); });
@@ -330,7 +312,7 @@
} }
// 기존 휴대폰 번호 복원 // 기존 휴대폰 번호 복원
$(document).ready(function() { $(document).ready(function () {
const savedMobileNumber = $('#mobileNumber').val(); const savedMobileNumber = $('#mobileNumber').val();
if (savedMobileNumber) { if (savedMobileNumber) {
const parts = savedMobileNumber.split('-'); const parts = savedMobileNumber.split('-');
@@ -343,4 +325,5 @@
}); });
</script> </script>
</th:block> </th:block>
</html> </html>
@@ -32,7 +32,7 @@
<div class="found-users-list"> <div class="found-users-list">
<div class="found-user-item" th:each="user, stat : ${foundUsers}"> <div class="found-user-item" th:each="user, stat : ${foundUsers}">
<div class="user-info"> <div class="user-info">
<span class="user-email" th:text="${user.maskedEmailAddr}">te***@example.com</span> <span class="user-email" th:text="${user.loginId}">test@example.com</span>
<span class="user-date"> <span class="user-date">
(<th:block th:text="${#temporals.format(user.createdDate, 'yyyy.MM.dd')}">2024.01.01</th:block> 가입) (<th:block th:text="${#temporals.format(user.createdDate, 'yyyy.MM.dd')}">2024.01.01</th:block> 가입)
</span> </span>
@@ -149,34 +149,21 @@
}); });
} }
// 로그인 전 중복 접속 확인 → 중복 시 기존 세션 강제 로그아웃 여부 질의 // 동시 접속 확인은 비밀번호 검증(1차 인증) 통과 후 서버가 안내한다
function checkDuplicateAndLogin(form) { // (2FA pending 또는 /login?duplicate=1 대기 상태에서 duplicateInfo 모델로 수신).
var loginId = form.id.value; function duplicateConfirmMessage(info) {
$.ajax({ return '해당 계정은 이미 다른 곳(' + info.ipAddress + ')에서 접속 중입니다.<br>'
url: /*[[@{/api/session/check-duplicate}]]*/ '/api/session/check-duplicate', + '접속 시각: ' + info.loginTime + '<br><br>'
type: 'POST',
data: { loginId: loginId },
success: function (data) {
if (data && data.duplicateSession) {
$('#loginLoading').removeClass('active');
var msg = '해당 계정은 이미 다른 곳(' + data.ipAddress + ')에서 접속 중입니다.<br>'
+ '접속 시각: ' + data.loginTime + '<br><br>'
+ '기존 접속을 해제하고 로그인하시겠습니까?'; + '기존 접속을 해제하고 로그인하시겠습니까?';
customPopups.showConfirm(msg, function (confirmed) {
if (confirmed) {
$('#loginLoading').addClass('active');
form.submit();
} }
});
} else { // 세션 CSRF 헤더 (meta[name=_csrf]) — 인증 후 확인/취소 POST 용
form.submit(); function csrfHeaders() {
} var t = document.querySelector('meta[name="_csrf"]');
}, var h = document.querySelector('meta[name="_csrf_header"]');
error: function () { var headers = {};
// 중복 체크 실패 시 로그인은 그대로 진행 headers[h ? h.getAttribute('content') : 'X-XSRF-TOKEN'] = t ? t.getAttribute('content') : '';
form.submit(); return headers;
}
});
} }
function fnInit() { function fnInit() {
@@ -242,7 +229,7 @@
customPopups.showAlert('[[#{login.passLengthShort}]]'); customPopups.showAlert('[[#{login.passLengthShort}]]');
} else { } else {
refreshCsrfAndThen(form, function () { refreshCsrfAndThen(form, function () {
checkDuplicateAndLogin(form); form.submit();
}); });
} }
} }
@@ -260,9 +247,12 @@
} }
}); });
// 로그인 2FA: 1차 인증 통과 후 pending 상태면 추가 인증 팝업 자동 오픈 // 1차 인증(비밀번호) 통과 후 흐름: [동시 접속 확인] → [2FA] 순서.
var twoFactorPending = [[${twoFactorPending}]]; var twoFactorPending = [[${twoFactorPending}]];
if (twoFactorPending && typeof TwoFactorAuth !== 'undefined') { var duplicateConfirmPending = [[${duplicateConfirmPending}]];
var duplicateInfo = [[${duplicateInfo}]];
function openLoginTwoFactor() {
TwoFactorAuth.open({ TwoFactorAuth.open({
mode: 'login', mode: 'login',
onSuccess: function (res) { onSuccess: function (res) {
@@ -279,6 +269,53 @@
}); });
} }
if (twoFactorPending && typeof TwoFactorAuth !== 'undefined') {
// 로그인 2FA pending: 동시 접속이 있으면 확인 후 2FA 팝업, 취소 시 로그인 포기
if (duplicateInfo) {
customPopups.showConfirm(duplicateConfirmMessage(duplicateInfo), function (confirmed) {
if (confirmed) {
openLoginTwoFactor();
} else {
$.ajax({
url: /*[[@{/auth/2fa/cancel}]]*/ '/auth/2fa/cancel',
type: 'POST',
headers: csrfHeaders(),
data: { reason: 'CANCELLED' }
});
customPopups.showAlert('로그인이 취소되었습니다.');
}
});
} else {
openLoginTwoFactor();
}
} else if (duplicateConfirmPending && duplicateInfo) {
// 2FA off + 동시 접속: 확인 시 서버가 로그인 확정(기존 접속 해제), 취소 시 포기
customPopups.showConfirm(duplicateConfirmMessage(duplicateInfo), function (confirmed) {
var url = confirmed
? /*[[@{/login/duplicate/confirm}]]*/ '/login/duplicate/confirm'
: /*[[@{/login/duplicate/cancel}]]*/ '/login/duplicate/cancel';
$.ajax({
url: url,
type: 'POST',
headers: csrfHeaders(),
success: function (res) {
if (confirmed) {
if (res && res.valid && res.redirect) {
window.location.href = res.redirect;
} else {
customPopups.showAlert((res && res.message) || '로그인 확인이 만료되었습니다. 다시 로그인해주세요.');
}
}
},
error: function () {
if (confirmed) {
customPopups.showAlert('로그인 처리 중 오류가 발생했습니다. 다시 로그인해주세요.');
}
}
});
});
}
fnInit(); fnInit();
}); });
</script> </script>
@@ -1,14 +1,15 @@
<!doctype html> <!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" <html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}"> xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
<body> <body>
<section layout:fragment="title"> <section layout:fragment="title">
<div class="page-title-banner"> <div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image"> <img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>비밀번호 초기화</h1> <h1>비밀번호 초기화</h1>
</div> </div>
</section> </section>
<th:block layout:fragment="contentFragment"> <th:block layout:fragment="contentFragment">
<div class="account-recovery-page"> <div class="account-recovery-page">
<div class="account-recovery-container"> <div class="account-recovery-container">
<div class="account-recovery-card"> <div class="account-recovery-card">
@@ -22,32 +23,23 @@
<div id="alertContainer"></div> <div id="alertContainer"></div>
<!-- Account Recovery Form --> <!-- Account Recovery Form -->
<form id="accountForm" role="form" name="accountForm" th:action="@{/reset_password}" method="post" class="account-recovery-form"> <form id="accountForm" role="form" name="accountForm" th:action="@{/reset_password}" method="post"
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/> class="account-recovery-form">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<input type="hidden" id="mobileNumber" name="mobileNumber" th:value="${mobileNumber}"> <input type="hidden" id="mobileNumber" name="mobileNumber" th:value="${mobileNumber}">
<!-- Name Field --> <!-- Name Field -->
<div class="form-group"> <div class="form-group">
<label for="userName" class="form-label">성명<span class="required-badge">필수</span></label> <label for="userName" class="form-label">성명<span class="required-badge">필수</span></label>
<input type="text" <input type="text" id="userName" name="userName" th:value="${userName}" class="form-input"
id="userName" placeholder="성명" required>
name="userName"
th:value="${userName}"
class="form-input"
placeholder="성명"
required>
</div> </div>
<!-- Email ID Field --> <!-- Email ID Field -->
<div class="form-group"> <div class="form-group">
<label for="loginId" class="form-label">이메일 아이디<span class="required-badge">필수</span></label> <label for="loginId" class="form-label">이메일 아이디<span class="required-badge">필수</span></label>
<input type="email" <input type="email" id="loginId" name="loginId" th:value="${loginId}" class="form-input"
id="loginId" placeholder="이메일 아이디" required>
name="loginId"
th:value="${loginId}"
class="form-input"
placeholder="이메일 아이디"
required>
</div> </div>
<!-- Phone Number Fields --> <!-- Phone Number Fields -->
@@ -64,27 +56,13 @@
<option value="018">018</option> <option value="018">018</option>
</select> </select>
<span class="separator">-</span> <span class="separator">-</span>
<input type="tel" <input type="tel" id="phoneMiddle" name="phoneMiddle" maxlength="4" class="form-input phone-number"
id="phoneMiddle" placeholder="1234" pattern="[0-9]*" inputmode="numeric" required>
name="phoneMiddle"
maxlength="4"
class="form-input phone-number"
placeholder="1234"
pattern="[0-9]*"
inputmode="numeric"
required>
<span class="separator">-</span> <span class="separator">-</span>
<input type="tel" <input type="tel" id="phoneLast" name="phoneLast" maxlength="4" class="form-input phone-number"
id="phoneLast" placeholder="1234" pattern="[0-9]*" inputmode="numeric" required>
name="phoneLast"
maxlength="4"
class="form-input phone-number"
placeholder="1234"
pattern="[0-9]*"
inputmode="numeric"
required>
</div> </div>
<button type="button" class="btn org-btn-check btn_auth" id="requestAuthButton"> <button type="button" class="org-btn-check btn_auth" id="requestAuthButton">
인증번호 받기 인증번호 받기
</button> </button>
</div> </div>
@@ -93,16 +71,9 @@
<!-- Auth Number Input --> <!-- Auth Number Input -->
<div class="form-group auth-number-group" id="authNumberGroup" style="display: none;"> <div class="form-group auth-number-group" id="authNumberGroup" style="display: none;">
<label for="authNumber" class="form-label">인증번호 입력<span class="required-badge">필수</span></label> <label for="authNumber" class="form-label">인증번호 입력<span class="required-badge">필수</span></label>
<div class="auth-input-group"> <div class="auth-input-group mg-y">
<input type="text" <input type="text" id="authNumber" name="authNumber" maxlength="6" class="form-input auth-input"
id="authNumber" placeholder="인증번호 6자리" pattern="[0-9]*" inputmode="numeric" required>
name="authNumber"
maxlength="6"
class="form-input auth-input"
placeholder="인증번호 6자리"
pattern="[0-9]*"
inputmode="numeric"
required>
<span class="auth-timer" id="authTimer">03:00</span> <span class="auth-timer" id="authTimer">03:00</span>
</div> </div>
<button type="button" class="auth-verify-button" id="verifyAuthButton"> <button type="button" class="auth-verify-button" id="verifyAuthButton">
@@ -124,17 +95,17 @@
</div> </div>
</div> </div>
</div> </div>
</th:block> </th:block>
</body> </body>
<th:block layout:fragment="contentScript"> <th:block layout:fragment="contentScript">
<script th:inline="javascript"> <script th:inline="javascript">
$(document).ready(function() { $(document).ready(function () {
const errorMsg = [[${error}]]; const errorMsg = [[${ error }]];
if (errorMsg) { if (errorMsg) {
customPopups.showAlert(errorMsg, 'error'); customPopups.showAlert(errorMsg, 'error');
} }
const successMsg = [[${success}]]; const successMsg = [[${ success }]];
if (successMsg) { if (successMsg) {
customPopups.showAlert(successMsg, 'success'); customPopups.showAlert(successMsg, 'success');
} }
@@ -191,7 +162,7 @@
} }
// 인증번호 요청 버튼 // 인증번호 요청 버튼
$('#requestAuthButton').on('click', function() { $('#requestAuthButton').on('click', function () {
const userName = $('#userName').val().trim(); const userName = $('#userName').val().trim();
if (!userName) { if (!userName) {
customPopups.showAlert('성명을 입력해주세요.', 'error'); customPopups.showAlert('성명을 입력해주세요.', 'error');
@@ -226,7 +197,7 @@
mobileNumber: mobileNumber, mobileNumber: mobileNumber,
_csrf: $('input[name="_csrf"]').val() _csrf: $('input[name="_csrf"]').val()
}, },
success: function(response) { success: function (response) {
$('#loadingOverlay').fadeOut(200); $('#loadingOverlay').fadeOut(200);
if (response.valid) { if (response.valid) {
@@ -251,7 +222,7 @@
customPopups.showAlert(response.message || '인증번호 발송에 실패했습니다.', 'error'); customPopups.showAlert(response.message || '인증번호 발송에 실패했습니다.', 'error');
} }
}, },
error: function() { error: function () {
$('#loadingOverlay').fadeOut(200); $('#loadingOverlay').fadeOut(200);
customPopups.showAlert('처리 중 오류가 발생했습니다. 다시 시도해주세요.', 'error'); customPopups.showAlert('처리 중 오류가 발생했습니다. 다시 시도해주세요.', 'error');
} }
@@ -259,7 +230,7 @@
}); });
// 인증번호 확인 버튼 // 인증번호 확인 버튼
$('#verifyAuthButton').on('click', function() { $('#verifyAuthButton').on('click', function () {
if (!isAuthNumberRequested) { if (!isAuthNumberRequested) {
customPopups.showAlert('휴대폰 번호 인증을 먼저 진행해주세요.', 'error'); customPopups.showAlert('휴대폰 번호 인증을 먼저 진행해주세요.', 'error');
return; return;
@@ -283,7 +254,7 @@
authNumber: authNumber, authNumber: authNumber,
_csrf: $('input[name="_csrf"]').val() _csrf: $('input[name="_csrf"]').val()
}, },
success: function(response) { success: function (response) {
$('#loadingOverlay').fadeOut(200); $('#loadingOverlay').fadeOut(200);
if (response.valid) { if (response.valid) {
@@ -301,7 +272,7 @@
customPopups.showAlert(response.message || '인증번호가 일치하지 않습니다.', 'error'); customPopups.showAlert(response.message || '인증번호가 일치하지 않습니다.', 'error');
} }
}, },
error: function() { error: function () {
$('#loadingOverlay').fadeOut(200); $('#loadingOverlay').fadeOut(200);
customPopups.showAlert('처리 중 오류가 발생했습니다. 다시 시도해주세요.', 'error'); customPopups.showAlert('처리 중 오류가 발생했습니다. 다시 시도해주세요.', 'error');
} }
@@ -309,7 +280,7 @@
}); });
// 폼 제출 // 폼 제출
$('#accountForm').on('submit', function(e) { $('#accountForm').on('submit', function (e) {
e.preventDefault(); e.preventDefault();
const userName = $('#userName').val().trim(); const userName = $('#userName').val().trim();
@@ -351,7 +322,7 @@
}); });
// 취소 버튼 // 취소 버튼
$('#cancelButton').on('click', function() { $('#cancelButton').on('click', function () {
window.location.href = '/login'; window.location.href = '/login';
}); });
@@ -383,7 +354,7 @@
} }
// 기존 휴대폰 번호 복원 // 기존 휴대폰 번호 복원
$(document).ready(function() { $(document).ready(function () {
const savedMobileNumber = $('#mobileNumber').val(); const savedMobileNumber = $('#mobileNumber').val();
if (savedMobileNumber) { if (savedMobileNumber) {
const parts = savedMobileNumber.split('-'); const parts = savedMobileNumber.split('-');
@@ -396,4 +367,5 @@
}); });
</script> </script>
</th:block> </th:block>
</html> </html>
@@ -17,7 +17,7 @@
<p class="hero-subtitle">세상의 모든 서비스</p> <p class="hero-subtitle">세상의 모든 서비스</p>
<h2 class="hero-title">DJBank API가<br>함께 합니다.</h2> <h2 class="hero-title">DJBank API가<br>함께 합니다.</h2>
</div> </div>
<a href="#" class="btn-hero-signup">회원가입하기 <i class="bi bi-chevron-right"></i></a> <a href="#" class="btn-hero-signup">회원 가입 안내 <i class="bi bi-chevron-right"></i></a>
</div> </div>
<div class="hero-image-content"> <div class="hero-image-content">
<!-- Inline SVG Tech Illustration --> <!-- Inline SVG Tech Illustration -->
@@ -327,8 +327,8 @@
DJBank API Portal은 기업이 혁신적인 금융 서비스를 쉽고 신속하게 개발하고 구현할 수 있도록 엄선된 API 명세와 직관적인 샌드박스 테스트 환경을 무상으로 지원합니다. DJBank API Portal은 기업이 혁신적인 금융 서비스를 쉽고 신속하게 개발하고 구현할 수 있도록 엄선된 API 명세와 직관적인 샌드박스 테스트 환경을 무상으로 지원합니다.
</p> </p>
<div class="action-buttons"> <div class="action-buttons">
<a href="#" class="action-btn btn-secondary">처음 만나는 DJBank API</a> <a th:href="@{/service/intro}" class="action-btn btn-secondary">처음 만나는 DJBank API</a>
<a href="#" class="action-btn btn-primary">피드백 / 개선요청 <i class="bi bi-patch-question"></i></a> <a th:href="@{/partnership}" class="action-btn btn-primary">피드백 / 개선요청 <i class="bi bi-patch-question"></i></a>
</div> </div>
</div> </div>
<div class="info-image-box"> <div class="info-image-box">
@@ -607,6 +607,7 @@
pendingInvitation: [[${ session.pendingInvitation }]], pendingInvitation: [[${ session.pendingInvitation }]],
pendingInvitationToken: [[${ session.pendingInvitationToken }]], pendingInvitationToken: [[${ session.pendingInvitationToken }]],
pendingInvitationOrgName: [[${ session.pendingInvitationOrgName }]], pendingInvitationOrgName: [[${ session.pendingInvitationOrgName }]],
needClientRegister: [[${ needClientRegister }]],
sessionSuccessMsg: [[${ session.success }]], sessionSuccessMsg: [[${ session.success }]],
redirectUrl: [[${ session.redirectUrl }]] redirectUrl: [[${ session.redirectUrl }]]
}); });
@@ -21,10 +21,10 @@
<div class="app-management-content"> <div class="app-management-content">
<!-- Header --> <!-- Header -->
<div class="app-management-header"> <div class="app-management-header">
<h2>인증 키 관리</h2> <h2>클라이언트/API 신청 관리</h2>
<div sec:authorize="hasRole('ROLE_API_KEY_REQUEST')"> <div sec:authorize="hasRole('ROLE_API_KEY_REQUEST')">
<button type="button" class="btn-action-primary md" id="requestApiKey"> <button type="button" class="btn-action-primary md" id="requestApiKey">
생성 클라이언트 생성
</button> </button>
</div> </div>
</div> </div>
@@ -35,7 +35,7 @@
<!-- App Requests (Pending) --> <!-- App Requests (Pending) -->
<th:block th:if="${appRequests != null and !appRequests.isEmpty()}"> <th:block th:if="${appRequests != null and !appRequests.isEmpty()}">
<a class="app-card-figma" th:each="request : ${appRequests}" <a class="app-card-figma" th:each="request : ${appRequests}"
th:href="@{/myapikey/app_request_detail(id=${request.id})}"> th:href="@{/clients/app_request_detail(id=${request.id})}">
<!-- App Icon --> <!-- App Icon -->
<div class="app-card-icon-box"> <div class="app-card-icon-box">
@@ -85,7 +85,7 @@
<!-- API Keys (Approved/Inactive) --> <!-- API Keys (Approved/Inactive) -->
<th:block th:if="${apiKeys != null and !apiKeys.isEmpty()}"> <th:block th:if="${apiKeys != null and !apiKeys.isEmpty()}">
<a class="app-card-figma" th:each="apikey : ${apiKeys}" <a class="app-card-figma" th:each="apikey : ${apiKeys}"
th:href="@{/myapikey/credential_detail(id=${apikey.clientid})}"> th:href="@{/clients/credential_detail(id=${apikey.clientid})}">
<!-- App Icon --> <!-- App Icon -->
<div class="app-card-icon-box"> <div class="app-card-icon-box">
@@ -156,7 +156,7 @@
keysToRemove.forEach(key => sessionStorage.removeItem(key)); keysToRemove.forEach(key => sessionStorage.removeItem(key));
// Redirect to the API key registration wizard with clear parameter // Redirect to the API key registration wizard with clear parameter
window.location.href = '/myapikey/register/step1?clear=true'; window.location.href = '/clients/register/step1?clear=true';
} }
if (requestApiKeyBtn) { if (requestApiKeyBtn) {
@@ -22,12 +22,12 @@
<div class="step1-wrap"> <div class="step1-wrap">
<!-- Title --> <!-- Title -->
<h2 class="s1-title">앱 수정</h2> <h2 class="s1-title">클라이언트 정보 - 변경</h2>
<!-- Progress Steps Card --> <!-- Progress Steps Card -->
<div class="s1-progress-card"> <div class="s1-progress-card">
<div class="s1-steps"> <div class="s1-steps">
<!-- Step 1 Active: 정보수정 (form/input icon) --> <!-- Step 1 Active: 클라이언트 정보수정 (form/input icon) -->
<div class="s1-step s1-step--active"> <div class="s1-step s1-step--active">
<div class="s1-step-circle"> <div class="s1-step-circle">
<svg class="s1-step-svg" width="26" height="26" viewBox="0 0 24 24" fill="none" <svg class="s1-step-svg" width="26" height="26" viewBox="0 0 24 24" fill="none"
@@ -39,7 +39,7 @@
</svg> </svg>
</div> </div>
<span class="s1-step-num">1단계</span> <span class="s1-step-num">1단계</span>
<span class="s1-step-name"> 정보수정</span> <span class="s1-step-name">클라이언트 정보수정</span>
</div> </div>
<div class="s1-step-line"></div> <div class="s1-step-line"></div>
@@ -71,7 +71,7 @@
</svg> </svg>
</div> </div>
<span class="s1-step-num">3단계</span> <span class="s1-step-num">3단계</span>
<span class="s1-step-name">완료</span> <span class="s1-step-name">변경 신청 완료</span>
</div> </div>
</div> </div>
@@ -84,48 +84,12 @@
<!-- Form Card --> <!-- Form Card -->
<div class="s1-form-card"> <div class="s1-form-card">
<form id="modifyStep1Form" method="post" th:action="@{/myapikey/modify/step1}" <form id="modifyStep1Form" method="post" th:action="@{/clients/modify/step1}"
th:object="${apiKeyModification}" enctype="multipart/form-data"> th:object="${apiKeyModification}" enctype="multipart/form-data">
<!-- Hidden clientId --> <!-- Hidden clientId -->
<input type="hidden" th:field="*{clientId}"/> <input type="hidden" th:field="*{clientId}"/>
<!-- 앱 아이콘 -->
<div class="s1-field">
<label class="s1-label">앱 아이콘</label>
<div class="s1-upload-box" id="iconDropZone">
<!-- 기존 파일 ID로 이미지 노출 -->
<img class="s1-preview-img" id="iconPreviewImage1"
th:if="${apiKeyModification.appIconFileId != null}"
th:src="@{/file/download(fileSn=1,fileId=${apiKeyModification.appIconFileId})}"
alt="미리보기" style="display: block;">
<!-- 데이터에서 이미지 노출 -->
<img class="s1-preview-img" id="iconPreviewImage2"
th:if="${apiKeyModification.appIconData != null and apiKeyModification.appIconFileId == null}"
th:src="'data:' + ${apiKeyModification.appIconContentType} + ';base64,' + ${T(java.util.Base64).getEncoder().encodeToString(apiKeyModification.appIconData)}"
alt="미리보기" style="display: block;">
<!-- JS용 이미지 타겟 -->
<img id="iconPreviewImageJS" class="s1-preview-img" style="display: none;" alt="미리보기">
<div id="iconPlaceholder" class="s1-upload-inner" th:style="${(apiKeyModification.appIconData != null or apiKeyModification.appIconFileId != null) ? 'display: none;' : 'display: flex;'}">
<img th:src="@{/img/icon/img_icon.png}">
<p class="s1-upload-title">앱 아이콘 이미지 파일을 업로드 하세요</p>
<p class="s1-upload-hint">권장 사이즈는 1280 * 12 픽셀이며 JPG,PNG,GIF 파일만 등록할 수 있습니다.</p>
<button type="button" class="s1-btn-upload"
onclick="document.getElementById('appIconFile').click()">
이미지 파일 업로드
</button>
</div>
<button type="button" class="s1-btn-remove-icon" id="btnRemoveIcon"
th:style="${(apiKeyModification.appIconData != null or apiKeyModification.appIconFileId != null) ? 'display: flex;' : 'display: none;'}"
onclick="removeAppIcon()">✕</button>
<input type="file" id="appIconFile" name="appIcon" accept="image/png,image/jpeg,image/jpg,image/gif" style="display:none;">
</div>
</div>
<!-- 앱 이름 --> <!-- 앱 이름 -->
<div class="s1-field"> <div class="s1-field">
<label class="s1-label">앱 이름 <span class="s1-required">*</span></label> <label class="s1-label">앱 이름 <span class="s1-required">*</span></label>
@@ -143,13 +107,6 @@
</div> </div>
</div> </div>
<!-- Call Back URL -->
<div class="s1-field">
<label class="s1-label">Call Back URL</label>
<input type="text" id="callbackUrl" name="callbackUrl" th:field="*{callbackUrl}" class="s1-input"
placeholder="URL을 입력해 주세요.">
</div>
<!-- 화이트 리스트 --> <!-- 화이트 리스트 -->
<div class="s1-field"> <div class="s1-field">
<label class="s1-label">화이트 리스트 <span class="s1-required">*</span></label> <label class="s1-label">화이트 리스트 <span class="s1-required">*</span></label>
@@ -175,12 +132,48 @@
<input type="hidden" id="ipWhitelist" name="ipWhitelist" value=""> <input type="hidden" id="ipWhitelist" name="ipWhitelist" value="">
</div> </div>
<!-- 앱 아이콘 (선택 옵션 — 최하단 배치, compact) -->
<div class="s1-field">
<label class="s1-label">앱 아이콘</label>
<div class="s1-upload-box s1-upload-box--compact" id="iconDropZone">
<!-- 기존 파일 ID로 이미지 노출 -->
<img class="s1-preview-img" id="iconPreviewImage1"
th:if="${apiKeyModification.appIconFileId != null}"
th:src="@{/file/download(fileSn=1,fileId=${apiKeyModification.appIconFileId})}"
alt="미리보기" style="display: block;">
<!-- 데이터에서 이미지 노출 -->
<img class="s1-preview-img" id="iconPreviewImage2"
th:if="${apiKeyModification.appIconData != null and apiKeyModification.appIconFileId == null}"
th:src="'data:' + ${apiKeyModification.appIconContentType} + ';base64,' + ${T(java.util.Base64).getEncoder().encodeToString(apiKeyModification.appIconData)}"
alt="미리보기" style="display: block;">
<!-- JS용 이미지 타겟 -->
<img id="iconPreviewImageJS" class="s1-preview-img" style="display: none;" alt="미리보기">
<div id="iconPlaceholder" class="s1-upload-inner" th:style="${(apiKeyModification.appIconData != null or apiKeyModification.appIconFileId != null) ? 'display: none;' : 'display: flex;'}">
<img th:src="@{/img/icon/img_icon.png}">
<p class="s1-upload-title">앱 아이콘 이미지 파일을 업로드 하세요</p>
<p class="s1-upload-hint">권장 사이즈는 512 * 512 픽셀이며 JPG,PNG,GIF 파일만 등록할 수 있습니다.</p>
<button type="button" class="s1-btn-upload"
onclick="document.getElementById('appIconFile').click()">
이미지 파일 업로드
</button>
</div>
<button type="button" class="s1-btn-remove-icon" id="btnRemoveIcon"
th:style="${(apiKeyModification.appIconData != null or apiKeyModification.appIconFileId != null) ? 'display: flex;' : 'display: none;'}"
onclick="removeAppIcon()">✕</button>
<input type="file" id="appIconFile" name="appIcon" accept="image/png,image/jpeg,image/jpg,image/gif" style="display:none;">
</div>
</div>
</form> </form>
</div> </div>
<!-- 다음 버튼 --> <!-- 다음 버튼 -->
<div class="s1-actions" style="justify-content: flex-end; gap: 10px;"> <div class="s1-actions" style="justify-content: flex-end; gap: 10px;">
<a th:href="@{/myapikey/modify/cancel(clientId=${apiKeyModification.clientId})}" class="s1-btn-next" style="background: #bdc7cf; flex: none; width: 120px; text-decoration: none; display: flex; justify-content: center; align-items: center;">취소</a> <a th:href="@{/clients/modify/cancel(clientId=${apiKeyModification.clientId})}" class="s1-btn-next" style="background: #bdc7cf; flex: none; width: 120px; text-decoration: none; display: flex; justify-content: center; align-items: center;">취소</a>
<button type="submit" form="modifyStep1Form" class="s1-btn-next" style="flex: none; width: 120px;">다음</button> <button type="submit" form="modifyStep1Form" class="s1-btn-next" style="flex: none; width: 120px;">다음</button>
</div> </div>
@@ -211,7 +204,8 @@
removeBtn.style.display = 'none'; removeBtn.style.display = 'none';
} }
function addIpAddress() { function addIpAddress(refocus) {
if (refocus === undefined) refocus = true;
const ipInput = document.getElementById('ipWhitelistInput'); const ipInput = document.getElementById('ipWhitelistInput');
const ipValue = ipInput.value.trim(); const ipValue = ipInput.value.trim();
@@ -234,7 +228,7 @@
ipAddresses.push(ipValue); ipAddresses.push(ipValue);
renderIpList(); renderIpList();
ipInput.value = ''; ipInput.value = '';
ipInput.focus(); if (refocus) ipInput.focus();
} }
function removeIpAddress(ip) { function removeIpAddress(ip) {
@@ -349,15 +343,24 @@
} }
// IP input Enter key // IP input Enter key
ipInput.addEventListener('keypress', function (e) { ipInput.addEventListener('blur', function (e) {
if (e.key === 'Enter') { e.preventDefault(); addIpAddress(); } const rt = e.relatedTarget;
if (rt && rt.closest && rt.closest('.s1-ip-btn--add')) return;
if (!ipInput.value.trim()) return; // 빈 값은 무시
addIpAddress(false); // blur 경로: 재포커스 안 함
});
// Enter/Tab 모두 입력값 등록. Tab 은 다음 tab-stop(추가 버튼)으로 포커스만
// 이동(onclick 미발동)하므로 keydown 에서 직접 추가한다. blur 보다 먼저 실행되어
// 중복 없이 처리된다.
ipInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter') { e.preventDefault(); addIpAddress(); return; }
if (e.key === 'Tab' && ipInput.value.trim()) { addIpAddress(false); }
}); });
// Form submit validation // Form submit validation
form.addEventListener('submit', function (e) { form.addEventListener('submit', function (e) {
const name = document.getElementById('appName').value.trim(); const name = document.getElementById('appName').value.trim();
const desc = textarea.value.trim(); const desc = textarea.value.trim();
const url = document.getElementById('callbackUrl').value.trim();
if (!name) { if (!name) {
e.preventDefault(); e.preventDefault();
@@ -372,15 +375,6 @@
textarea.focus(); textarea.focus();
return; return;
} }
if (url) {
try { new URL(url); } catch (_) {
e.preventDefault();
customPopups.showAlert('올바른 URL 형식이 아닙니다.\n예: https://example.com/callback');
document.getElementById('callbackUrl').focus();
return;
}
}
}); });
}); });
</script> </script>
@@ -23,12 +23,12 @@
<div class="step2-wrap"> <div class="step2-wrap">
<!-- Title --> <!-- Title -->
<h2 class="s2-title">앱 수정</h2> <h2 class="s2-title">클라이언트 정보 - 변경</h2>
<!-- Progress Steps Card --> <!-- Progress Steps Card -->
<div class="s1-progress-card"> <div class="s1-progress-card">
<div class="s1-steps"> <div class="s1-steps">
<!-- Step 1: 정보수정 (completed) --> <!-- Step 1: 클라이언트 정보수정 (completed) -->
<div class="s1-step"> <div class="s1-step">
<div class="s1-step-circle"> <div class="s1-step-circle">
<!-- 입력폼 아이콘 --> <!-- 입력폼 아이콘 -->
@@ -44,7 +44,7 @@
</svg> </svg>
</div> </div>
<span class="s1-step-num">1단계</span> <span class="s1-step-num">1단계</span>
<span class="s1-step-name"> 정보수정</span> <span class="s1-step-name">클라이언트 정보수정</span>
</div> </div>
<div class="s1-step-line"></div> <div class="s1-step-line"></div>
@@ -86,13 +86,13 @@
</svg> </svg>
</div> </div>
<span class="s1-step-num">3단계</span> <span class="s1-step-num">3단계</span>
<span class="s1-step-name">앱 수정 완료</span> <span class="s1-step-name">변경 신청 완료</span>
</div> </div>
</div> </div>
</div> </div>
<!-- API 선택 공용 모듈 --> <!-- API 선택 공용 모듈 -->
<th:block th:replace="~{fragment/api_selector :: apiSelector(${apiServices}, ${apiKeyModification.selectedApis}, '/myapikey/modify/step2', '/myapikey/modify/step2/save')}"/> <th:block th:replace="~{fragment/api_selector :: apiSelector(${apiServices}, ${apiKeyModification.selectedApis}, '/clients/modify/step2', '/clients/modify/step2/save')}"/>
<!-- clientId 는 모듈 폼에 form 속성으로 주입 --> <!-- clientId 는 모듈 폼에 form 속성으로 주입 -->
<input type="hidden" name="clientId" th:value="${apiKeyModification.clientId}" form="apiSelectorForm"/> <input type="hidden" name="clientId" th:value="${apiKeyModification.clientId}" form="apiSelectorForm"/>
@@ -111,6 +111,38 @@
</div> </div>
</div> </div>
</div> </div>
<script th:if="${error}" th:inline="javascript">
document.addEventListener('DOMContentLoaded', function () {
customPopups.showAlert(/*[[${error}]]*/ '');
});
</script>
<script th:inline="javascript">
// 반영 직전 2FA: twofaRequired 면 최종 저장 제출을 가로채 2FA 팝업 → 성공 시 실제 제출.
// api-selector.js 의 submit 리스너(선택 검증·hidden 동기화)가 먼저 실행된 뒤 동작한다.
// "이전" 버튼(btnPrevStep)은 form.submit() 직접 호출이라 submit 이벤트를 타지 않음 → 2FA 미적용.
document.addEventListener('DOMContentLoaded', function () {
var twofaRequired = /*[[${twofaRequired}]]*/ false;
if (!twofaRequired) return;
var form = document.getElementById('apiSelectorForm');
if (!form) return;
form.addEventListener('submit', function (e) {
// 앞선 리스너가 검증 실패로 막았거나(미선택 등) 이미 취소된 제출이면 개입하지 않는다.
if (e.defaultPrevented) return;
e.preventDefault();
if (typeof TwoFactorAuth === 'undefined') { form.submit(); return; }
TwoFactorAuth.open({
mode: 'stepup',
purpose: '/clients/modify/step2',
// form.submit() 은 submit 이벤트를 재발생시키지 않으므로 그대로 서버로 전송된다.
onSuccess: function () { form.submit(); },
onCancel: function () { /* 사용자 취소 — step2 유지 */ }
});
});
});
</script>
</th:block> </th:block>
<!-- 화면 전체 오버레이/플로팅은 body 직속(pagePopups)으로 렌더 → wrapper transform·overflow 영향 없이 뷰포트 기준 중앙 정렬 --> <!-- 화면 전체 오버레이/플로팅은 body 직속(pagePopups)으로 렌더 → wrapper transform·overflow 영향 없이 뷰포트 기준 중앙 정렬 -->
@@ -24,12 +24,12 @@
<div class="step3-wrap"> <div class="step3-wrap">
<!-- Title --> <!-- Title -->
<h2 class="s3-title">앱 수정</h2> <h2 class="s3-title">클라이언트 정보 - 변경</h2>
<!-- Progress Steps Card --> <!-- Progress Steps Card -->
<div class="s1-progress-card"> <div class="s1-progress-card">
<div class="s1-steps"> <div class="s1-steps">
<!-- Step 1: 정보수정 (form/input icon) --> <!-- Step 1: 클라이언트 정보수정 (form/input icon) -->
<div class="s1-step"> <div class="s1-step">
<div class="s1-step-circle"> <div class="s1-step-circle">
<!-- 입력폼 아이콘 --> <!-- 입력폼 아이콘 -->
@@ -45,7 +45,7 @@
</svg> </svg>
</div> </div>
<span class="s1-step-num">1단계</span> <span class="s1-step-num">1단계</span>
<span class="s1-step-name"> 정보수정</span> <span class="s1-step-name">클라이언트 정보수정</span>
</div> </div>
<div class="s1-step-line"></div> <div class="s1-step-line"></div>
@@ -87,7 +87,7 @@
</svg> </svg>
</div> </div>
<span class="s1-step-num">3단계</span> <span class="s1-step-num">3단계</span>
<span class="s1-step-name">앱 수정 완료</span> <span class="s1-step-name">변경 신청 완료</span>
</div> </div>
</div> </div>
</div> </div>
@@ -151,7 +151,7 @@
</div> </div>
<div class="s3-message-wrapper"> <div class="s3-message-wrapper">
<h1 class="s3-success-title">앱 수정이 완료되었습니다.</h1> <h1 class="s3-success-title">클라이언트 변경 신청이 완료되었습니다.</h1>
<p class="s3-success-desc"> <p class="s3-success-desc">
<span class="s3-highlight">담당자 승인 후 변경 사항이 적용됩니다.</span> <span class="s3-highlight">담당자 승인 후 변경 사항이 적용됩니다.</span>
<br> <br>
@@ -161,7 +161,7 @@
<!-- Bottom Actions --> <!-- Bottom Actions -->
<div class="s3-actions"> <div class="s3-actions">
<a href="/myapikey" class="s3-btn-complete"> <a href="/clients" class="s3-btn-complete">
완료 완료
</a> </a>
</div> </div>
@@ -179,10 +179,10 @@
</p> </p>
</div> </div>
<div class="s3-actions"> <div class="s3-actions">
<a href="/myapikey" class="s3-btn-retry"> <a href="/clients" class="s3-btn-retry">
다시 시도 다시 시도
</a> </a>
<a href="/myapikey" class="s3-btn-list"> <a href="/clients" class="s3-btn-list">
목록으로 목록으로
</a> </a>
</div> </div>
@@ -24,7 +24,7 @@
<div class="step1-wrap"> <div class="step1-wrap">
<!-- Title --> <!-- Title -->
<h2 class="s1-title">앱생성</h2> <h2 class="s1-title">클라이언트 정보 - 신규</h2>
<!-- Progress Steps Card --> <!-- Progress Steps Card -->
<div class="s1-progress-card"> <div class="s1-progress-card">
@@ -84,7 +84,7 @@
</svg> </svg>
</div> </div>
<span class="s1-step-num">3단계</span> <span class="s1-step-num">3단계</span>
<span class="s1-step-name">앱 생성 완료</span> <span class="s1-step-name">클라이언트 신청 완료</span>
</div> </div>
</div> </div>
@@ -97,30 +97,9 @@
<!-- Form Card --> <!-- Form Card -->
<div class="s1-form-card"> <div class="s1-form-card">
<form id="registerStep1Form" method="post" th:action="@{/myapikey/register/step1}" <form id="registerStep1Form" method="post" th:action="@{/clients/register/step1}"
th:object="${apiKeyRegistration}" enctype="multipart/form-data"> th:object="${apiKeyRegistration}" enctype="multipart/form-data">
<!-- 앱 아이콘 -->
<div class="s1-field">
<label class="s1-label">앱 아이콘</label>
<div class="s1-upload-box" id="iconDropZone">
<img id="iconPreviewImage" class="s1-preview-img" style="display:none;" alt="미리보기">
<div id="iconPlaceholder" class="s1-upload-inner">
<img th:src="@{/img/icon/img_icon.png}">
<p class="s1-upload-title">앱 아이콘 이미지 파일을 업로드 하세요</p>
<p class="s1-upload-hint">권장 사이즈는 1280 * 12 픽셀이며 JPG,PNG,GIF 파일만 등록할 수 있습니다.</p>
<button type="button" class="s1-btn-upload"
onclick="document.getElementById('appIconFile').click()">
이미지 파일 업로드
</button>
</div>
<button type="button" class="s1-btn-remove-icon" id="btnRemoveIcon" style="display:none;"
onclick="removeAppIcon()">✕</button>
<input type="file" id="appIconFile" name="appIcon" accept="image/png,image/jpeg,image/jpg,image/gif"
style="display:none;">
</div>
</div>
<!-- 앱 이름 --> <!-- 앱 이름 -->
<div class="s1-field"> <div class="s1-field">
<label class="s1-label">앱 이름 <span class="s1-required">*</span></label> <label class="s1-label">앱 이름 <span class="s1-required">*</span></label>
@@ -171,6 +150,27 @@
<input type="hidden" id="ipWhitelist" name="ipWhitelist" value=""> <input type="hidden" id="ipWhitelist" name="ipWhitelist" value="">
</div> </div>
<!-- 앱 아이콘 (선택 옵션 — 최하단 배치, compact) -->
<div class="s1-field">
<label class="s1-label">앱 아이콘</label>
<div class="s1-upload-box s1-upload-box--compact" id="iconDropZone">
<img id="iconPreviewImage" class="s1-preview-img" style="display:none;" alt="미리보기">
<div id="iconPlaceholder" class="s1-upload-inner">
<img th:src="@{/img/icon/img_icon.png}">
<p class="s1-upload-title">앱 아이콘 이미지 파일을 업로드 하세요</p>
<p class="s1-upload-hint">권장 사이즈는 512 * 512 픽셀이며 JPG,PNG,GIF 파일만 등록할 수 있습니다.</p>
<button type="button" class="s1-btn-upload"
onclick="document.getElementById('appIconFile').click()">
이미지 파일 업로드
</button>
</div>
<button type="button" class="s1-btn-remove-icon" id="btnRemoveIcon" style="display:none;"
onclick="removeAppIcon()">✕</button>
<input type="file" id="appIconFile" name="appIcon" accept="image/png,image/jpeg,image/jpg,image/gif"
style="display:none;">
</div>
</div>
</form> </form>
</div> </div>
@@ -202,7 +202,8 @@
removeBtn.style.display = 'none'; removeBtn.style.display = 'none';
} }
function addIpAddress() { function addIpAddress(refocus) {
if (refocus === undefined) refocus = true;
const ipInput = document.getElementById('ipWhitelistInput'); const ipInput = document.getElementById('ipWhitelistInput');
const ipValue = ipInput.value.trim(); const ipValue = ipInput.value.trim();
@@ -225,7 +226,7 @@
ipAddresses.push(ipValue); ipAddresses.push(ipValue);
renderIpList(); renderIpList();
ipInput.value = ''; ipInput.value = '';
ipInput.focus(); if (refocus) ipInput.focus();
} }
function removeIpAddress(ip) { function removeIpAddress(ip) {
@@ -355,9 +356,21 @@
}); });
} }
// IP input Enter key // IP input Enter/Tab key — 추가 버튼을 지나쳐도 입력값을 등록한다.
ipInput.addEventListener('keypress', function (e) { // Tab 은 다음 tab-stop(추가 버튼)으로 포커스만 이동(onclick 미발동)하므로
if (e.key === 'Enter') { e.preventDefault(); addIpAddress(); } // keydown 에서 직접 추가한다. blur 보다 먼저 실행되어 중복 없이 처리된다.
ipInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter') { e.preventDefault(); addIpAddress(); return; }
if (e.key === 'Tab' && ipInput.value.trim()) { addIpAddress(false); }
});
// 포커스 아웃 시 자동 추가/검증 (사용자가 "추가" 버튼을 지나치는 경우 대응).
// "추가" 버튼으로 포커스 이동 시엔 버튼 onclick 이 처리하므로 중복 추가 방지.
ipInput.addEventListener('blur', function (e) {
const rt = e.relatedTarget;
if (rt && rt.closest && rt.closest('.s1-ip-btn--add')) return;
if (!ipInput.value.trim()) return; // 빈 값은 무시
addIpAddress(false); // blur 경로: 재포커스 안 함
}); });
// Form submit validation // Form submit validation
@@ -23,7 +23,7 @@
<div class="step2-wrap"> <div class="step2-wrap">
<!-- Title --> <!-- Title -->
<h2 class="s2-title">앱생성</h2> <h2 class="s2-title">클라이언트 정보 - 신규</h2>
<!-- Progress Steps Card --> <!-- Progress Steps Card -->
<div class="s1-progress-card"> <div class="s1-progress-card">
@@ -86,14 +86,14 @@
</svg> </svg>
</div> </div>
<span class="s1-step-num">3단계</span> <span class="s1-step-num">3단계</span>
<span class="s1-step-name">앱 생성 완료</span> <span class="s1-step-name">클라이언트 신청 완료</span>
</div> </div>
</div> </div>
</div> </div>
<!-- API 선택 공용 모듈 --> <!-- API 선택 공용 모듈 -->
<th:block <th:block
th:replace="~{fragment/api_selector :: apiSelector(${apiServices}, ${apiKeyRegistration.selectedApis}, '/myapikey/register/step2', '/myapikey/register/step2/save')}" /> th:replace="~{fragment/api_selector :: apiSelector(${apiServices}, ${apiKeyRegistration.selectedApis}, '/clients/register/step2', '/clients/register/step2/save')}" />
<!-- Bottom Navigation Actions --> <!-- Bottom Navigation Actions -->
<div class="s2-actions"> <div class="s2-actions">
@@ -24,7 +24,7 @@
<div class="step3-wrap"> <div class="step3-wrap">
<!-- Title --> <!-- Title -->
<h2 class="s3-title">앱생성</h2> <h2 class="s3-title">클라이언트 정보 - 신규</h2>
<!-- Progress Steps Card --> <!-- Progress Steps Card -->
<div class="s1-progress-card"> <div class="s1-progress-card">
@@ -87,7 +87,7 @@
</svg> </svg>
</div> </div>
<span class="s1-step-num">3단계</span> <span class="s1-step-num">3단계</span>
<span class="s1-step-name">앱 생성 완료</span> <span class="s1-step-name">클라이언트 신청 완료</span>
</div> </div>
</div> </div>
</div> </div>
@@ -151,7 +151,7 @@
</div> </div>
<div class="s3-message-wrapper"> <div class="s3-message-wrapper">
<h1 class="s3-success-title">앱 생성이 완료되었습니다.</h1> <h1 class="s3-success-title">클라이언트 신청이 완료되었습니다.</h1>
<p class="s3-success-desc"> <p class="s3-success-desc">
담당자 앱 승인 후 <span class="s3-highlight">[앱 정보]</span> 화면에서 담당자 앱 승인 후 <span class="s3-highlight">[앱 정보]</span> 화면에서
<br> <br>
@@ -161,7 +161,7 @@
<!-- Bottom Actions --> <!-- Bottom Actions -->
<div class="s3-actions"> <div class="s3-actions">
<a href="/myapikey" class="s3-btn-complete"> <a href="/clients" class="s3-btn-complete">
완료 완료
</a> </a>
</div> </div>
@@ -179,10 +179,10 @@
</p> </p>
</div> </div>
<div class="s3-actions"> <div class="s3-actions">
<a href="/myapikey/register/step1" class="s3-btn-retry"> <a href="/clients/register/step1" class="s3-btn-retry">
다시 시도 다시 시도
</a> </a>
<a href="/myapikey" class="s3-btn-list"> <a href="/clients" class="s3-btn-list">
목록으로 목록으로
</a> </a>
</div> </div>
@@ -158,13 +158,13 @@
</div> </div>
<div class="btn_wrap bt_location" th:if="${appRequest.approval.approvalStatus.toString() == 'REQUESTED'}"> <div class="btn_wrap bt_location" th:if="${appRequest.approval.approvalStatus.toString() == 'REQUESTED'}">
<form th:action="@{/myapikey/api_key_request/cancel}" method="post"> <form th:action="@{/clients/api_key_request/cancel}" method="post">
<input type="hidden" name="id" th:value="${appRequest.id}"> <input type="hidden" name="id" th:value="${appRequest.id}">
<button type="submit" class="btn_del">취소</button> <button type="submit" class="btn_del">취소</button>
</form> </form>
</div> </div>
<div class="btn_inventory btn_mtop"> <div class="btn_inventory btn_mtop">
<a th:href="@{/myapikey/api_key_request/history}" class="common_btn_type_1">목록</a> <a th:href="@{/clients/api_key_request/history}" class="common_btn_type_1">목록</a>
</div> </div>
</div> </div>
</div> </div>
@@ -15,10 +15,10 @@
<div class="tabs"> <div class="tabs">
<ul class="tab_nav tab_bt"> <ul class="tab_nav tab_bt">
<li class="active"> <li class="active">
<a th:href="@{/myapikey/api_key_request/history}">개발</a> <a th:href="@{/clients/api_key_request/history}">개발</a>
</li> </li>
<li> <li>
<a th:href="@{/myapikey/api_key_request/prod_history}">운영</a> <a th:href="@{/clients/api_key_request/prod_history}">운영</a>
</li> </li>
</ul> </ul>
<div class="tab active"> <div class="tab active">
@@ -49,7 +49,7 @@
<tbody> <tbody>
<tr th:each="request, status : ${requests}"> <tr th:each="request, status : ${requests}">
<td th:text="${status.index + 1}">1</td> <td th:text="${status.index + 1}">1</td>
<td><a th:href="@{/myapikey/api_key_request/detail(id=${request.id})}" class="btn btn-sm btn-primary" <td><a th:href="@{/clients/api_key_request/detail(id=${request.id})}" class="btn btn-sm btn-primary"
th:text="'[' + ${request.clientName} + '] ' + ${request.type.description}"></a></td> th:text="'[' + ${request.clientName} + '] ' + ${request.type.description}"></a></td>
<td th:text="${request.approval.approvalStatus.description}">1</td> <td th:text="${request.approval.approvalStatus.description}">1</td>
<td th:text="${#temporals.format(request.approval.createdDate, 'yyyy-MM-dd')}"></td> <td th:text="${#temporals.format(request.approval.createdDate, 'yyyy-MM-dd')}"></td>
@@ -85,7 +85,7 @@
<tbody> <tbody>
<tr th:each="request, status : ${requests}"> <tr th:each="request, status : ${requests}">
<td th:text="${status.index + 1}">1</td> <td th:text="${status.index + 1}">1</td>
<td><a th:href="@{/myapikey/api_key_request/detail(id=${request.id})}" class="btn btn-sm btn-primary" th:text="${request.clientName}"></a></td> <td><a th:href="@{/clients/api_key_request/detail(id=${request.id})}" class="btn btn-sm btn-primary" th:text="${request.clientName}"></a></td>
<td th:text="${request.approval.approvalStatus}">1</td> <td th:text="${request.approval.approvalStatus}">1</td>
<td th:text="${#temporals.format(request.approval.createdDate, 'yyyy-MM-dd')}"></td> <td th:text="${#temporals.format(request.approval.createdDate, 'yyyy-MM-dd')}"></td>
<td th:text="${#temporals.format(request.approval.approvalDate, 'yyyy-MM-dd')}"></td> <td th:text="${#temporals.format(request.approval.approvalDate, 'yyyy-MM-dd')}"></td>
@@ -98,7 +98,7 @@
</div> </div>
</div> </div>
<div class="pagination" th:replace="~{fragment/pagination :: pagination(jsFunction='fn_select_page')}"></div> <div class="pagination" th:replace="~{fragment/pagination :: pagination(jsFunction='fn_select_page')}"></div>
<form name="listForm" th:action="@{/myapikey/api_key_request/history}" method="get"> <form name="listForm" th:action="@{/clients/api_key_request/history}" method="get">
<input type="hidden" id="page" name="page"/> <input type="hidden" id="page" name="page"/>
</form> </form>
</div> </div>
@@ -112,7 +112,7 @@
<script> <script>
function fn_select_page(pageNo) { function fn_select_page(pageNo) {
document.listForm.page.value = pageNo; document.listForm.page.value = pageNo;
document.listForm.action = '[[@{/myapikey/api_key_request/history}]]'; document.listForm.action = '[[@{/clients/api_key_request/history}]]';
document.listForm.submit(); document.listForm.submit();
} }
</script> </script>
@@ -231,7 +231,7 @@
신청 취소 신청 취소
</button> </button>
<!-- List Button (gray) --> <!-- List Button (gray) -->
<a th:href="@{/myapikey}" class="dt-btn-gray"> <a th:href="@{/clients}" class="dt-btn-gray">
목록 목록
</a> </a>
</div> </div>
@@ -289,7 +289,7 @@
$('.loading-overlay').show(); $('.loading-overlay').show();
$.ajax({ $.ajax({
url: '/myapikey/api_key_request/cancel', url: '/clients/api_key_request/cancel',
type: 'POST', type: 'POST',
data: { id: requestId }, data: { id: requestId },
dataType: 'json', dataType: 'json',
@@ -299,7 +299,7 @@
}).done(function (response) { }).done(function (response) {
if (response.success) { if (response.success) {
alert(response.message || '신청이 취소되었습니다.'); alert(response.message || '신청이 취소되었습니다.');
window.location.href = /*[[@{/myapikey}]]*/ '/myapikey'; window.location.href = /*[[@{/clients}]]*/ '/clients';
} else { } else {
alert(response.message || '신청 취소 중 오류가 발생했습니다.'); alert(response.message || '신청 취소 중 오류가 발생했습니다.');
$('.loading-overlay').hide(); $('.loading-overlay').hide();
@@ -1,29 +1,29 @@
<!DOCTYPE html> <!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"> <html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<body> <body>
<th:block th:fragment="commonUserInfo"> <th:block th:fragment="commonUserInfo">
<div class="form-row"> <div class="form-row">
<div class="form-label-wrapper label-offset"> <div class="form-label-wrapper">
<span class="form-label-text">이메일 아이디</span> <span class="form-label-text">이메일 아이디</span>
</div> </div>
<div class="form-field-wrapper"> <div class="form-field-wrapper">
<input type="text" th:value="${loginId}" name="loginId" class="form-input input-readonly" <input type="text" th:value="${loginId}" name="loginId" class="form-input input-readonly"
placeholder="이메일" disabled="disabled"> placeholder="이메일" disabled="disabled" style="background: #ededed !important;">
<input type="hidden" th:value="${loginId}" name="loginId"> <input type="hidden" th:value="${loginId}" name="loginId">
</div> </div>
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="form-label-wrapper label-offset"> <div class="form-label-wrapper">
<span class="form-label-text">성명</span> <span class="required-badge">필수</span> <span class="form-label-text">성명</span> <span class="required-badge">필수</span>
</div> </div>
<div class="form-field-wrapper"> <div class="form-field-wrapper">
<input type="text" th:value="${userName}" id="userName" name="userName" class="form-input" <input type="text" th:value="${userName}" id="userName" name="userName" class="form-input">
placeholder="성명">
</div> </div>
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="form-label-wrapper label-offset"> <div class="form-label-wrapper">
<span class="form-label-text">휴대폰 번호</span> <span class="required-badge">필수</span> <span class="form-label-text">휴대폰 번호</span> <span class="required-badge">필수</span>
</div> </div>
<div class="form-field-wrapper input-with-button" <div class="form-field-wrapper input-with-button"
@@ -34,32 +34,21 @@
mobileMiddle=${hasHyphen ? (parts != null and #arrays.length(parts) > 1 ? parts[1] : '') : (len == 11 ? #strings.substring(mobileNumber, 3, 7) : (len == 10 ? #strings.substring(mobileNumber, 3, 6) : ''))}, mobileMiddle=${hasHyphen ? (parts != null and #arrays.length(parts) > 1 ? parts[1] : '') : (len == 11 ? #strings.substring(mobileNumber, 3, 7) : (len == 10 ? #strings.substring(mobileNumber, 3, 6) : ''))},
mobileLast=${hasHyphen ? (parts != null and #arrays.length(parts) > 2 ? parts[2] : '') : (len == 11 ? #strings.substring(mobileNumber, 7, 11) : (len == 10 ? #strings.substring(mobileNumber, 6, 10) : ''))}"> mobileLast=${hasHyphen ? (parts != null and #arrays.length(parts) > 2 ? parts[2] : '') : (len == 11 ? #strings.substring(mobileNumber, 7, 11) : (len == 10 ? #strings.substring(mobileNumber, 6, 10) : ''))}">
<div class="compound-input"> <div class="compound-input">
<input type="text" <input type="text" th:value="${mobilePrefix}" name="mobilePrefix" class="form-input input-readonly"
th:value="${mobilePrefix}" maxlength="3" disabled="disabled" style="background: #ededed !important;">
name="mobilePrefix"
class="form-input input-readonly"
maxlength="3"
disabled="disabled">
<span class="separator">-</span> <span class="separator">-</span>
<input type="text" <input type="text" th:value="${mobileMiddle}" name="mobileMiddle" class="form-input input-readonly"
th:value="${mobileMiddle}" maxlength="4" disabled="disabled" style="background: #ededed !important;">
name="mobileMiddle"
class="form-input input-readonly"
maxlength="4"
disabled="disabled">
<span class="separator">-</span> <span class="separator">-</span>
<input type="text" <input type="text" th:value="${mobileLast}" name="mobileLast" class="form-input input-readonly"
th:value="${mobileLast}" maxlength="4" disabled="disabled" style="background: #ededed !important;">
name="mobileLast"
class="form-input input-readonly"
maxlength="4"
disabled="disabled">
</div> </div>
<button type="button" class="btn-input-action btn-change change_mobile_phone" id="changePhoneBtn">변경</button> <button type="button" class="btn-input-action btn-change change_mobile_phone"
id="changePhoneBtn">변경</button>
</div> </div>
</div> </div>
<div class="form-row" id="newPhoneNumberContainer" style="display: none;"> <div class="form-row" id="newPhoneNumberContainer" style="display: none;">
<div class="form-label-wrapper label-offset"> <div class="form-label-wrapper">
<span class="form-label-text">새 휴대폰 번호</span> <span class="required-badge">필수</span> <span class="form-label-text">새 휴대폰 번호</span> <span class="required-badge">필수</span>
</div> </div>
<div class="form-field-wrapper input-with-button"> <div class="form-field-wrapper input-with-button">
@@ -75,27 +64,26 @@
</select> </select>
<span class="separator">-</span> <span class="separator">-</span>
<input type="text" id="newMobileMiddle" name="newMobileMiddle" class="form-input phone-number" <input type="text" id="newMobileMiddle" name="newMobileMiddle" class="form-input phone-number"
maxlength="4" pattern="\d*" inputmode="numeric" placeholder="앞자리"/> maxlength="4" pattern="\d*" inputmode="numeric" placeholder="앞자리" />
<span class="separator">-</span> <span class="separator">-</span>
<input type="text" id="newMobileLast" name="newMobileLast" class="form-input phone-number" <input type="text" id="newMobileLast" name="newMobileLast" class="form-input phone-number"
maxlength="4" pattern="\d*" inputmode="numeric" placeholder="뒷자리"/> maxlength="4" pattern="\d*" inputmode="numeric" placeholder="뒷자리" />
<input type="hidden" value="" name="mobileNumber" id="mobileNumber"> <input type="hidden" value="" name="mobileNumber" id="mobileNumber">
</div> </div>
<button type="button" class="btn-input-action btn-auth btn_auth"> <button type="button" class="btn-input-action btn-auth btn_auth ">
인증번호 받기 인증번호 받기
</button> </button>
</div> </div>
</div> </div>
<div class="form-row" id="authNumberContainer" style="display: none;"> <div class="form-row" id="authNumberContainer" style="display: none;">
<div class="form-label-wrapper label-offset"> <div class="form-label-wrapper">
<span class="form-label-text">인증번호 입력</span> <span class="required-badge">필수</span> <span class="form-label-text">인증번호 입력</span> <span class="required-badge">필수</span>
</div> </div>
<div class="form-field-wrapper input-with-button"> <div class="form-field-wrapper input-with-button">
<div class="auth-input-group"> <div class="auth-input-group">
<input type="text" id="authNumber" name="authNumber" maxlength="6" pattern="[0-9]*" <input type="text" id="authNumber" name="authNumber" maxlength="6" pattern="[0-9]*"
inputmode="numeric" class="form-input" inputmode="numeric" class="form-input" placeholder="SMS 인증번호 6자리">
placeholder="SMS 인증번호 6자리">
<span class="auth-timer" id="certify_time">03:00</span> <span class="auth-timer" id="certify_time">03:00</span>
</div> </div>
<button type="button" class="btn-input-action btn-auth btn_verify_auth"> <button type="button" class="btn-input-action btn-auth btn_verify_auth">
@@ -104,8 +92,8 @@
</div> </div>
</div> </div>
<div id="authNumber-validation"></div> <div id="authNumber-validation"></div>
</th:block> </th:block>
<th:block th:fragment="commonUserInfoScript"> <th:block th:fragment="commonUserInfoScript">
<script> <script>
// 휴대폰 번호 조합 함수 // 휴대폰 번호 조합 함수
function combineMobileNumber() { function combineMobileNumber() {
@@ -119,7 +107,7 @@
let middle = document.getElementById('newMobileMiddle')?.value.trim() || ''; let middle = document.getElementById('newMobileMiddle')?.value.trim() || '';
let last = document.getElementById('newMobileLast')?.value.trim() || ''; let last = document.getElementById('newMobileLast')?.value.trim() || '';
console.log('values:', {prefix, middle, last}); console.log('values:', { prefix, middle, last });
if (!prefix || !middle || !last) { if (!prefix || !middle || !last) {
return null; return null;
@@ -348,10 +336,10 @@
mobileNumber: newMobileNumber, mobileNumber: newMobileNumber,
_csrf: $('input[name="_csrf"]').val() _csrf: $('input[name="_csrf"]').val()
}, },
success: function(response) { success: function (response) {
handleAuthNumberRequest(response); handleAuthNumberRequest(response);
}, },
error: function(xhr, status, error) { error: function (xhr, status, error) {
customPopups.showAlert("처리 중 오류가 발생했습니다. 다시 시도해주세요."); customPopups.showAlert("처리 중 오류가 발생했습니다. 다시 시도해주세요.");
} }
}); });
@@ -379,10 +367,10 @@
authNumber: authNumber, authNumber: authNumber,
_csrf: $('input[name="_csrf"]').val() _csrf: $('input[name="_csrf"]').val()
}, },
success: function(response) { success: function (response) {
handleAuthNumberVerification(response); handleAuthNumberVerification(response);
}, },
error: function(xhr, status, error) { error: function (xhr, status, error) {
customPopups.showAlert("처리 중 오류가 발생했습니다. 다시 시도해주세요."); customPopups.showAlert("처리 중 오류가 발생했습니다. 다시 시도해주세요.");
} }
}); });
@@ -408,6 +396,7 @@
initializeMobileNumber(); initializeMobileNumber();
}); });
</script> </script>
</th:block> </th:block>
</body> </body>
</html> </html>
@@ -25,7 +25,7 @@
<div class="detail-wrap"> <div class="detail-wrap">
<!-- Title Bar --> <!-- Title Bar -->
<div class="board-header"> <div class="board-header">
<h2 class="board-title">인증키 정보</h2> <h2 class="board-title">클라이언트 정보</h2>
<span class="board-desc">등록된 앱의 상세 정보를 확인할 수 있습니다.</span> <span class="board-desc">등록된 앱의 상세 정보를 확인할 수 있습니다.</span>
</div> </div>
@@ -188,11 +188,11 @@
<!-- Bottom Navigation Actions --> <!-- Bottom Navigation Actions -->
<div class="dt-actions" style="justify-content: flex-end; gap: 11px; margin-top: 30px;"> <div class="dt-actions" style="justify-content: flex-end; gap: 11px; margin-top: 30px;">
<a th:href="@{/myapikey}" class="dt-btn-gray">목록</a> <a th:href="@{/clients}" class="dt-btn-gray">목록</a>
<button type="button" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" class="dt-btn-red" <button type="button" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" class="dt-btn-red"
th:data-client-id="${apiKey.clientid}" onclick="deleteApiKeyFromButton(this)">인증키 삭제</button> th:data-client-id="${apiKey.clientid}" onclick="deleteApiKeyFromButton(this)">API 이용 해지</button>
<a sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" class="dt-btn-blue" <a sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" class="dt-btn-blue"
th:href="@{/myapikey/modify/step1(clientId=${apiKey.clientid})}">수정</a> th:href="@{/clients/modify/step1(clientId=${apiKey.clientid})}">변경 신청</a>
</div> </div>
</div><!-- /detail-wrap --> </div><!-- /detail-wrap -->
@@ -218,24 +218,31 @@
); );
} }
// Show password prompt for viewing client secret (최초 1회 노출 + 서버측 물리 삭제) // Client Secret 조회 진입 — 확인 후 조회 (본인 확인은 step-up 2FA)
function showPasswordPrompt() { function showPasswordPrompt() {
customPopups.showPasswordInput({ customPopups.showConfirm(
title: 'Client Secret 조회', 'Client Secret<strong>지금 한 번만</strong> 조회할 수 있으며,<br>조회 즉시 값은 <strong>영구 삭제</strong>됩니다.<br><br>조회하시겠습니까?',
message: '보안을 위해 비밀번호를 입력해주세요.<br>조회 즉시 값은 영구 삭제됩니다.', function (confirmed) {
onConfirm: function (password) { if (!confirmed) {
return;
}
doRevealSecret();
}
);
}
// Secret 조회 요청 (step-up 필요 시 2FA 후 동일 요청 재시도)
function doRevealSecret() {
$.ajax({ $.ajax({
url: /*[[@{/myapikey/credential/reveal-secret}]]*/ '/myapikey/credential/reveal-secret', url: /*[[@{/clients/credential/reveal-secret}]]*/ '/clients/credential/reveal-secret',
type: 'POST', type: 'POST',
contentType: 'application/json', contentType: 'application/json',
data: JSON.stringify({ clientId: CREDENTIAL_CLIENT_ID, password: password }), data: JSON.stringify({ clientId: CREDENTIAL_CLIENT_ID }),
headers: { headers: {
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token' 'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
} }
}).done(function (response) { }).done(function (response) {
if (response.success) { if (response.success) {
customPopups.hidePasswordInput();
// 서버가 반환한 secret을 화면에 1회 주입 // 서버가 반환한 secret을 화면에 1회 주입
$('#revealedSecretValue').text(response.secret); $('#revealedSecretValue').text(response.secret);
$('#revealedSecretCopyBtn').attr('data-secret', response.secret).data('secret', response.secret); $('#revealedSecretCopyBtn').attr('data-secret', response.secret).data('secret', response.secret);
@@ -243,18 +250,16 @@
$('#hiddenSecretBox').hide(); $('#hiddenSecretBox').hide();
$('#revealedSecretBox').fadeIn(300); $('#revealedSecretBox').fadeIn(300);
} else if (response.alreadyRevealed) { } else if (response.alreadyRevealed) {
customPopups.hidePasswordInput();
showLostKeyGuide(); showLostKeyGuide();
} else { } else {
customPopups.showPasswordError(response.message || '비밀번호가 일치하지 않습니다.'); customPopups.showAlert(response.message || 'Client Secret 조회에 실패했습니다.');
} }
}).fail(function () { }).fail(function (jqXHR) {
customPopups.showPasswordError('오류가 발생했습니다. 다시 시도해주세요.'); if (isStepUpRequired(jqXHR)) {
}); requireStepUp('/clients/credential/reveal-secret', function () { doRevealSecret(); });
}, return;
onCancel: function () {
// User cancelled - do nothing
} }
customPopups.showAlert('오류가 발생했습니다. 다시 시도해주세요.');
}); });
} }
@@ -307,7 +312,7 @@
$('.loading-overlay').show(); $('.loading-overlay').show();
$.ajax({ $.ajax({
url: /*[[@{/myapikey/api_key_delete}]]*/ '/myapikey/api_key_delete', url: /*[[@{/clients/api_key_delete}]]*/ '/clients/api_key_delete',
type: 'POST', type: 'POST',
contentType: 'application/json', contentType: 'application/json',
data: JSON.stringify({ clientId: clientId }), data: JSON.stringify({ clientId: clientId }),
@@ -320,11 +325,11 @@
return; return;
} }
customPopups.showAlert(response.msg || 'API Key가 삭제되었습니다.', function () { customPopups.showAlert(response.msg || 'API Key가 삭제되었습니다.', function () {
window.location.href = /*[[@{/myapikey}]]*/ '/myapikey'; window.location.href = /*[[@{/clients}]]*/ '/clients';
}); });
}).fail(function (jqXHR, textStatus, errorThrown) { }).fail(function (jqXHR, textStatus, errorThrown) {
if (isStepUpRequired(jqXHR)) { if (isStepUpRequired(jqXHR)) {
requireStepUp('/myapikey/api_key_delete', function () { doDeleteApiKey(clientId); }); requireStepUp('/clients/api_key_delete', function () { doDeleteApiKey(clientId); });
return; return;
} }
customPopups.showAlert('API 삭제 요청 중 오류가 발생했습니다: ' + errorThrown); customPopups.showAlert('API 삭제 요청 중 오류가 발생했습니다: ' + errorThrown);
@@ -1,7 +1,7 @@
<!DOCTYPE html> <!DOCTYPE html>
<html xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" <html xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:th="http://www.thymeleaf.org" xmlns:th="http://www.thymeleaf.org"
layout:decorate="~{layout/djbank_base_layout}"> layout:decorate="~{layout/djbank_title_layout}">
<body> <body>
<section layout:fragment="title"> <section layout:fragment="title">
<div class="page-title-banner"> <div class="page-title-banner">
@@ -10,19 +10,32 @@
</div> </div>
</section> </section>
<section layout:fragment="contentFragment" class="content"> <section layout:fragment="contentFragment" class="content">
<div class="org-register-page corporate-register">
<div class="org-register-container"> <div class="org-register-container">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<input type="hidden" name="registrationType" th:value="${registrationType}"/> <input type="hidden" name="registrationType" th:value="${registrationType}"/>
<div class="register-card-wrapper">
<!-- Info Notice -->
<div class="org-info-notice">
<div class="notice-icon-wrapper">
<svg class="notice-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</div>
<p class="notice-text">법인회원 전환 신청 완료 후 서비스 또는 API 이용을 하실 수 있습니다.</p>
</div>
<!-- 약관동의 섹션 --> <!-- 약관동의 섹션 -->
<th:block <th:block th:replace="~{apps/register/userAgreementContent :: agreementContent(${termsOfUse}, ${privacyCollect})}"></th:block>
th:replace="apps/register/userAgreementContent :: agreementContent(${termsOfUse}, ${privacyCollect})"></th:block>
<!-- 법인 기본 정보 폼 --> <!-- 법인 기본 정보 폼 -->
<form name="portalOrg" id="businessTransferForm" method="post" th:action="@{/mypage/org-transfer}" <form name="portalOrg" id="businessTransferForm" method="post" th:action="@{/mypage/org-transfer}"
th:object="${portalOrg}" enctype="multipart/form-data"> th:object="${portalOrg}" enctype="multipart/form-data">
<div class="org-section-header"> <div class="org-section-header org-section-header--agreement">
<h3>법인 기본 정보</h3> <h3>법인 기본 정보</h3>
<span class="required-badge">필수 입력</span> <span class="required-badge">필수 입력</span>
</div> </div>
@@ -30,16 +43,20 @@
<th:block th:replace="~{apps/register/components/orgBasicInfoForm :: orgInfoForm}"></th:block> <th:block th:replace="~{apps/register/components/orgBasicInfoForm :: orgInfoForm}"></th:block>
<!-- 법인 관리자 정보 --> <!-- 법인 관리자 정보 -->
<div class="org-section-header"> <div class="org-section-header org-section-header--agreement" style="margin-top: 48px;">
<h3>법인 관리자 정보</h3> <h3>법인 관리자 정보</h3>
<span class="required-badge">필수 입력</span> <span class="required-badge">필수 입력</span>
</div> </div>
<div class="org-info-notice"> <div class="org-info-notice">
<ul> <div class="notice-icon-wrapper">
<li>법인 관리자는 API 신청 및 개발자 초대 등 모든 권한을 보유합니다.</li> <svg class="notice-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<li>법인회원 전환은 반드시 법인이메일 주소로 입력하시기 바랍니다. 개인이메일로 전환 신청시, 법인 승인이 거절 될 수 있습니다</li> <circle cx="12" cy="12" r="10" />
</ul> <line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</div>
<p class="notice-text">법인 관리자는 API 신청 및 개발자 초대 등 모든 권한을 보유합니다. 법인회원 전환은 반드시 법인이메일 주소로 입력하시기 바랍니다.</p>
</div> </div>
<div class="org-form-container"> <div class="org-form-container">
@@ -63,6 +80,8 @@
</div> </div>
</form> </form>
</div> </div>
</div>
</div>
</section> </section>
<th:block layout:fragment="contentScript"> <th:block layout:fragment="contentScript">
<script th:if="${error}" th:inline="javascript"> <script th:if="${error}" th:inline="javascript">
@@ -1,18 +1,19 @@
<!DOCTYPE html> <!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" <html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/djbank_title_layout}"> layout:decorate="~{layout/djbank_title_layout}">
<head> <head>
<title>비밀번호 변경</title> <title>비밀번호 변경</title>
</head> </head>
<body> <body>
<section layout:fragment="title"> <section layout:fragment="title">
<div class="page-title-banner"> <div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image"> <img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>비밀번호 변경</h1> <h1>비밀번호 변경</h1>
</div> </div>
</section> </section>
<section layout:fragment="contentFragment"> <section layout:fragment="contentFragment">
<div class="service-main app-management-layout"> <div class="service-main app-management-layout">
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('password')}"></th:block> <th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('password')}"></th:block>
<div class="app-management-content"> <div class="app-management-content">
@@ -20,16 +21,17 @@
<h2 class="page-outer-title">비밀번호 변경</h2> <h2 class="page-outer-title">비밀번호 변경</h2>
<form id="passwordChangeForm" th:action="@{/password/change}" method="post"> <form id="passwordChangeForm" th:action="@{/password/change}" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<div class="register-form-container"> <div class="register-form-container">
<div class="info-notice-box"> <div class="info-notice-box">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4685ef" stroke-width="2"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4685ef"
stroke-width="2">
<circle cx="12" cy="12" r="10"></circle> <circle cx="12" cy="12" r="10"></circle>
<path d="M12 16v-4"></path> <path d="M12 16v-4"></path>
<path d="M12 8h.01"></path> <path d="M12 8h.01"></path>
</svg> </svg>
<p>소중한 계정 보호를 위해 비밀번호를 변경해 주세요!</p> <p>소중한 계정 보호를 위해 비밀번호를 변경해 주세요!<br>
비밀번호 변경이 완료되면 자동으로 로그아웃되며, 새 비밀번호로 다시 로그인해야 합니다.</p>
</div> </div>
<div class="form-row"> <div class="form-row">
@@ -53,15 +55,25 @@
</div> </div>
<ul class="password-policy-checklist" data-password-input="newPassword"> <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">영문/숫자/특수문자 포함 8~50자</span></li> <li data-rule="length" class="is-idle"><span class="policy-icon"></span><span
<li data-rule="letter" class="is-idle"><span class="policy-icon"></span><span class="policy-text">영문 포함</span></li> class="policy-text">영문/숫자/특수문자 포함 8~50자</span></li>
<li data-rule="digit" class="is-idle"><span class="policy-icon"></span><span class="policy-text">숫자 포함</span></li> <li data-rule="letter" class="is-idle"><span class="policy-icon"></span><span
<li data-rule="special" class="is-idle"><span class="policy-icon"></span><span class="policy-text">특수문자 포함</span></li> class="policy-text">영문 포함</span></li>
<li data-rule="nospace" class="is-idle"><span class="policy-icon"></span><span class="policy-text">공백 사용 불가</span></li> <li data-rule="digit" class="is-idle"><span class="policy-icon"></span><span
<li data-rule="norepeat" class="is-idle"><span class="policy-icon"></span><span class="policy-text">동일 문자 3자리 이상 반복 불가</span></li> class="policy-text">숫자 포함</span></li>
<li data-rule="noseq" class="is-idle"><span class="policy-icon"></span><span class="policy-text">연속된 문자/숫자 3자리 이상 불가</span></li> <li data-rule="special" class="is-idle"><span class="policy-icon"></span><span
class="policy-text">특수문자 포함</span></li>
<li data-rule="nospace" class="is-idle"><span class="policy-icon"></span><span
class="policy-text">공백 사용 불가</span></li>
<li data-rule="norepeat" class="is-idle"><span class="policy-icon"></span><span
class="policy-text">동일 문자 3자리 이상 반복 불가</span></li>
<li data-rule="noseq" class="is-idle"><span class="policy-icon"></span><span
class="policy-text">연속된 문자/숫자 3자리 이상 불가</span></li>
<li data-rule="noid-server" class="is-idle"><span class="policy-icon"></span><span
class="policy-text">아이디(이메일) 포함 불가</span></li>
<li data-rule="nomobile-server" class="is-idle"><span class="policy-icon"></span><span
class="policy-text">휴대전화 번호 포함 불가</span></li>
</ul> </ul>
<p class="password-policy-note">아이디, 휴대전화 번호는 비밀번호에 사용할 수 없습니다.</p>
</div> </div>
<div class="form-actions" style="justify-content: flex-end;"> <div class="form-actions" style="justify-content: flex-end;">
@@ -75,10 +87,56 @@
</div> </div>
</div> </div>
<script th:if="${error}" th:inline="javascript"> <script th:if="${error}" th:inline="javascript">
$(document).ready(function() { $(document).ready(function () {
customPopups.showAlert([[${error}]]); customPopups.showAlert([[${ error }]]);
}) })
</script> </script>
<script th:inline="javascript">
// 아이디/휴대전화 포함 여부 라이브 체크 — 민감정보를 페이지에 내리지 않고
// 서버(/password/content-check, 세션 사용자 기준)로 판정한다. data-rule 이
// RULES 에 없는 *-server 항목은 password-policy.js 가 건드리지 않는다.
(function () {
var input = document.getElementById('newPassword');
var liId = document.querySelector('li[data-rule="noid-server"]');
var liMobile = document.querySelector('li[data-rule="nomobile-server"]');
if (!input || !liId || !liMobile) return;
function setState(li, state) {
li.classList.remove('is-idle', 'is-pass', 'is-fail');
li.classList.add(state);
}
var csrfToken = document.querySelector('meta[name="_csrf"]');
var csrfHeader = document.querySelector('meta[name="_csrf_header"]');
var timer = null;
input.addEventListener('input', function () {
var pw = input.value;
if (timer) clearTimeout(timer);
if (!pw) {
setState(liId, 'is-idle');
setState(liMobile, 'is-idle');
return;
}
timer = setTimeout(function () {
var headers = {};
if (csrfToken && csrfHeader) {
headers[csrfHeader.content] = csrfToken.content;
}
$.ajax({
url: /*[[@{/password/content-check}]]*/ '/password/content-check',
method: 'POST',
headers: headers,
data: { password: pw }
}).done(function (res) {
if (input.value !== pw) return; // 입력이 이미 바뀐 응답은 무시
setState(liId, res.idIncluded ? 'is-fail' : 'is-pass');
setState(liMobile, res.mobileIncluded ? 'is-fail' : 'is-pass');
});
}, 300);
});
})();
</script>
<script th:inline="javascript"> <script th:inline="javascript">
// 반영 직전 2FA: twofaRequired 면 제출을 가로채 2FA 팝업 → 성공 시 실제 제출. // 반영 직전 2FA: twofaRequired 면 제출을 가로채 2FA 팝업 → 성공 시 실제 제출.
(function () { (function () {
@@ -108,6 +166,7 @@
}); });
})(); })();
</script> </script>
</section> </section>
</body> </body>
</html> </html>
@@ -402,16 +402,17 @@
const last = document.getElementById('newMobileLast')?.value.trim(); const last = document.getElementById('newMobileLast')?.value.trim();
if (prefix === '선택' || !middle || !last) return null; if (prefix === '선택' || !middle || !last) return null;
return prefix + middle + last; // 저장 표준(하이픈 정규형)에 맞춰 조합
return `${prefix}-${middle}-${last}`;
}, },
// 기존 휴대폰 번호 가져오기 (하이픈 없이) // 기존 휴대폰 번호 가져오기 (하이픈 정규형)
getExistingMobileNumber: () => { getExistingMobileNumber: () => {
const prefix = document.querySelector('[name="mobilePrefix"]')?.value; const prefix = document.querySelector('[name="mobilePrefix"]')?.value;
const middle = document.querySelector('[name="mobileMiddle"]')?.value; const middle = document.querySelector('[name="mobileMiddle"]')?.value;
const last = document.querySelector('[name="mobileLast"]')?.value; const last = document.querySelector('[name="mobileLast"]')?.value;
return prefix + middle + last; return `${prefix}-${middle}-${last}`;
}, },
// 휴대폰 번호 변경 여부 확인 // 휴대폰 번호 변경 여부 확인
@@ -10,7 +10,7 @@
</section> </section>
<section layout:fragment="contentFragment"> <section layout:fragment="contentFragment">
<div class="org-register-container"> <div class="org-register-container">
<div class="corp-manager-wrapper">
<div class="common-title-bar"> <div class="common-title-bar">
<h2 class="common-title">기본 정보</h2> <h2 class="common-title">기본 정보</h2>
</div> </div>
@@ -19,7 +19,7 @@
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<div class="register-form"> <div class="register-form">
<div class="form-row"> <div class="form-row">
<div class="form-label-wrapper label-offset"> <div class="form-label-wrapper">
<span class="form-label-text">소속기관</span> <span class="form-label-text">소속기관</span>
</div> </div>
<div class="form-field-wrapper"> <div class="form-field-wrapper">
@@ -40,6 +40,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</section> </section>
<th:block layout:fragment="contentScript"> <th:block layout:fragment="contentScript">
@@ -76,7 +77,8 @@
const originalPrefix = document.querySelector('input[name="mobilePrefix"]').value; const originalPrefix = document.querySelector('input[name="mobilePrefix"]').value;
const originalMiddle = document.querySelector('input[name="mobileMiddle"]').value; const originalMiddle = document.querySelector('input[name="mobileMiddle"]').value;
const originalLast = document.querySelector('input[name="mobileLast"]').value; const originalLast = document.querySelector('input[name="mobileLast"]').value;
const originalMobileNumber = `${originalPrefix}${originalMiddle}${originalLast}`; // 저장 표준(하이픈 정규형)에 맞춰 조합 — 미변경 제출 시에도 이 값이 그대로 전송된다
const originalMobileNumber = `${originalPrefix}-${originalMiddle}-${originalLast}`;
// 현재 값들 가져오기 // 현재 값들 가져오기
const currentName = document.querySelector('input[name="userName"]').value.trim(); const currentName = document.querySelector('input[name="userName"]').value.trim();
@@ -102,7 +104,7 @@
// 하이픈 제거 후 비교 // 하이픈 제거 후 비교
const newMobileRaw = newMobileNumber.replace(/-/g, ''); const newMobileRaw = newMobileNumber.replace(/-/g, '');
if (newMobileRaw !== originalMobileNumber) { if (newMobileRaw !== originalMobileNumber.replace(/-/g, '')) {
hasChanges = true; hasChanges = true;
} }
@@ -1,73 +1,94 @@
<!DOCTYPE html> <!DOCTYPE html>
<html xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" <html xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
layout:decorate="~{layout/djbank_title_layout}">
<body> <body>
<section layout:fragment="title"> <section layout:fragment="title">
<div class="page-title-banner"> <div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image"> <img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>내 정보 관리</h1> <h1>내 정보 관리</h1>
</div> </div>
</section> </section>
<section layout:fragment="contentFragment"> <section layout:fragment="contentFragment">
<div class="org-register-container"> <div class="service-main container" style="padding-top: 70px; padding-bottom:100px;">
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('profile')}"></th:block>
<div class="app-management-content">
<div class="corp-manager-wrapper">
<!-- 초대 알림 배너 --> <!-- 초대 알림 배너 -->
<div th:if="${hasPendingInvitation}" class="invitation_alert_banner" style="background-color: #f0f8ff; border: 1px solid #2196F3; border-radius: 4px; padding: 15px 20px; margin: 20px 0;"> <div th:if="${hasPendingInvitation}" class="invitation_alert_banner"
style="background-color: #f0f8ff; border: 1px solid #2196F3; border-radius: 4px; padding: 15px 20px; margin: 20px 0;">
<div style="display: flex; align-items: center; justify-content: space-between;"> <div style="display: flex; align-items: center; justify-content: space-between;">
<div> <div>
<strong style="color: #1976D2;">법인 회원 초대가 대기 중입니다</strong> <strong style="color: #1976D2;">법인 회원 초대가 대기 중입니다</strong>
<p style="margin: 5px 0 0 0; color: #666;">기관에서 귀하를 법인 회원으로 초대했습니다. 초대를 수락하면 해당 기관의 법인 회원으로 전환됩니다.</p> <p style="margin: 5px 0 0 0; color: #666;">기관에서 귀하를 법인 회원으로 초대했습니다. 초대를 수락하면 해당 기관의 법인
회원으로 전환됩니다.</p>
</div> </div>
<div> <div>
<a th:href="@{/signup/decision(invitation=${invitationToken})}" class="common_btn_type_1 blue" style="white-space: nowrap;"> <a th:href="@{/signup/decision(invitation=${invitationToken})}"
class="common_btn_type_1 blue" style="white-space: nowrap;">
<span>초대 확인</span> <span>초대 확인</span>
</a> </a>
</div> </div>
</div> </div>
</div> </div>
<h2 class="page-outer-title">정보 관리</h2>
<div class="common-title-bar">
<h2 class="common-title">기본 정보</h2>
</div>
<div class="register-form-container">
<form id="updateForm" method="post" th:action="@{/mypage/update}" th:object="${user}"> <form id="updateForm" method="post" th:action="@{/mypage/update}" th:object="${user}">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<div class="register-form-container">
<!-- Section Title -->
<div class="inner-section-title">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#000"
stroke-width="2">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
<h3>기본 정보</h3>
</div>
<div class="register-form"> <div class="register-form">
<th:block th:replace="~{apps/mypage/components/commonUserInfo :: commonUserInfo}"></th:block> <th:block th:replace="~{apps/mypage/components/commonUserInfo :: commonUserInfo}">
</th:block>
</div> </div>
<!-- Corporate Transfer Button --> <!-- Corporate Transfer Button -->
<div class="corporate-transfer-section"> <div class="corporate-transfer-section" style="margin-top: 24px;">
<button type="button" class="btn btn-secondary btn_business btn-block"> <button type="button" class="btn btn_business"
style="background: #FFFFFF; border: 1.5px solid #2a69de; color: #2a69de; font-weight: 700; border-radius: 12px; padding: 12px 24px; font-size: 15px; cursor: pointer; transition: all 0.2s ease; display: inline-flex; align-items: center; justify-content: center; width: 100%; box-shadow: none;">
법인회원 전환 법인회원 전환
</button> </button>
</div> </div>
</form>
</div> </div>
<input type="hidden" name="finalMobileNumber"/> </form>
<input type="hidden" name="finalMobileNumber" />
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="form-actions form-actions--with-withdrawal"> <div class="form-actions">
<a class="withdrawal-link"><img th:src="@{/img/btn_withdrawal.png}" alt="회원탈퇴">회원탈퇴</a> <a class="withdrawal-link btn-withdrawal">회원탈퇴</a>
<div class="form-actions-buttons"> <div class="right-buttons">
<button type="button" class="btn btn-submit btn-secondary" th:onclick="|location.href='@{/}'|">취소</button> <button type="button" class="btn-cancel" th:onclick="|location.href='@{/}'|">취소</button>
<button type="button" class="btn btn-submit btn-primary submit-btn">수정</button> <button type="button" class="btn-apply btn-primary submit-btn">수정</button>
</div>
</div> </div>
</div> </div>
</section> </div>
<th:block layout:fragment="contentScript"> </div>
</div>
</section>
<th:block layout:fragment="contentScript">
<script th:if="${error}" th:inline="javascript"> <script th:if="${error}" th:inline="javascript">
$(document).ready(function () { $(document).ready(function () {
customPopups.showAlert([[${error}]]); customPopups.showAlert([[${ error }]]);
}); });
</script> </script>
<script th:inline="javascript"> <script th:inline="javascript">
$(document).ready(function() { $(document).ready(function () {
// 기존 success 메시지 처리 // 기존 success 메시지 처리
var successMsg = [[${success}]]; var successMsg = [[${ success }]];
console.log("Success message:", successMsg); console.log("Success message:", successMsg);
if (successMsg) { if (successMsg) {
console.log("Showing alert"); console.log("Showing alert");
@@ -190,9 +211,10 @@
} }
}); });
</script> </script>
</th:block> </th:block>
<section layout:fragment="pagePopups"> <section layout:fragment="pagePopups">
<th:block th:replace="~{fragment/popup/withdrawalPopup :: withdrawalPopup}"></th:block> <th:block th:replace="~{fragment/popup/withdrawalPopup :: withdrawalPopup}"></th:block>
</section> </section>
</body> </body>
</html> </html>
@@ -1,76 +1,78 @@
<!DOCTYPE html> <!DOCTYPE html>
<html xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" <html xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
layout:decorate="~{layout/djbank_title_layout}">
<body> <body>
<section layout:fragment="title"> <section layout:fragment="title">
<div class="page-title-banner"> <div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image"> <img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>이메일 인증</h1> <h1>이메일 인증</h1>
</div> </div>
</section> </section>
<section layout:fragment="contentFragment"> <section layout:fragment="contentFragment">
<div class="org-register-container"> <div class="service-main">
<div class="app-management-content">
<div class="password-change-wrapper">
<h2 class="page-outer-title">이메일 인증</h2>
<div class="common-title-bar">
<h2 class="common-title">이메일 인증</h2>
</div>
<div class="register-form-container">
<form id="verificationEmailForm" role="form" name="verificationEmailForm" method="post"> <form id="verificationEmailForm" role="form" name="verificationEmailForm" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<input type="hidden" id="userId" name="userId" th:value="${userId}"> <input type="hidden" id="userId" name="userId" th:value="${userId}">
<div class="register-form"> <div class="register-form-container">
<!-- 이메일 주소 + 인증코드 받기 버튼 (한 줄) --> <!-- 안내 Notice Box -->
<div class="form-row" style="margin-bottom: 0"> <div class="info-notice-box" style="margin-bottom: 24px;">
<div class="form-label-wrapper label-offset"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4685ef" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<path d="M12 16v-4"></path>
<path d="M12 8h.01"></path>
</svg>
<p>본인 확인을 위해 이메일 인증을 진행해 주세요. 아래 이메일 주소로 인증코드가 발송됩니다.</p>
</div>
<!-- 이메일 주소 행 -->
<div class="form-row">
<div class="form-label-wrapper">
<span class="form-label-text">이메일 주소</span> <span class="form-label-text">이메일 주소</span>
</div> </div>
<div class="form-field-wrapper input-with-button"> <div class="form-field-wrapper input-with-button">
<input type="email" id="email" name="email" class="form-input input-readonly" <input type="email" id="email" name="email" class="form-input input-readonly" th:value="${email}"
th:value="${email}" readonly disabled="disabled"> readonly disabled="disabled" style="flex: 1;">
<button type="button" class="btn-input-action btn-auth btn_send_code">인증코드 받기</button> <button type="button" class="btn-input-action btn-auth btn_send_code">인증코드 받기</button>
</div> </div>
</div> </div>
<div class="form-row" style="margin-bottom: 0"> <!-- 인증번호 입력 행 (초기 숨김) -->
<div class="form-label-wrapper label-offset">
<span class="form-label-text">&nbsp;</span>
</div>
<div class="form-field-wrapper">
<p class="form-hint" style="margin-top: 0;">위 이메일 주소로 인증코드가 발송됩니다.</p>
</div>
</div>
<!-- 인증번호 입력 -->
<div class="form-row" id="authCodeContainer" style="display: none;"> <div class="form-row" id="authCodeContainer" style="display: none;">
<div class="form-label-wrapper label-offset"> <div class="form-label-wrapper">
<span class="form-label-text">인증코드 입력</span> <span class="required-badge">필수</span> <span class="form-label-text">인증코드 입력</span>
</div> </div>
<div class="form-field-wrapper input-with-button"> <div class="form-field-wrapper input-with-button">
<div class="auth-input-group"> <div class="auth-input-group" style="flex: 1; position: relative;">
<input type="text" id="authCode" name="authCode" maxlength="6" pattern="[0-9]*" <input type="text" id="authCode" name="authCode" maxlength="6" pattern="[0-9]*" inputmode="numeric"
inputmode="numeric" class="form-input" class="form-input" placeholder="이메일로 받은 인증코드 6자리" style="width: 100%; padding-right: 70px;">
placeholder="이메일로 받은 인증코드 6자리"> <span class="auth-timer" id="certify_time"
<span class="auth-timer" id="certify_time">05:00</span> style="position: absolute; right: 16px; top: 50%; transform: translateY(-50%); font-weight: 500; font-size: 13px;">05:00</span>
</div> </div>
<button type="button" class="btn-input-action btn-auth btn_verify_code">인증코드 확인</button> <button type="button" class="btn-input-action btn-auth btn_verify_code">인증코드 확인</button>
</div> </div>
</div> </div>
</div> </div>
</form>
</div>
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="form-actions"> <div class="form-actions" style="justify-content: flex-end; margin-top: 24px;">
<button type="button" class="btn-submit btn-secondary">취소</button> <div class="right-buttons">
<button type="button" class="btn-cancel btn_cancel">취소</button>
</div> </div>
</div> </div>
</section> </form>
</div>
</div>
</div>
</section>
</body> </body>
<th:block layout:fragment="contentScript"> <th:block layout:fragment="contentScript">
<script th:inline="javascript"> <script th:inline="javascript">
$(document).ready(function() { $(document).ready(function () {
let timerInterval; let timerInterval;
let resendTimerInterval; let resendTimerInterval;
let isVerified = false; let isVerified = false;
@@ -83,10 +85,10 @@
const resendLimitSeconds = /*[[${@environment.getProperty('portal.auth.resend_limit_seconds', '60')}]]*/ 60; const resendLimitSeconds = /*[[${@environment.getProperty('portal.auth.resend_limit_seconds', '60')}]]*/ 60;
// 취소 버튼 클릭 시 메인 페이지로 이동 // 취소 버튼 클릭 시 메인 페이지로 이동
$('.btn_cancel').on('click', function() { $('.btn_cancel').on('click', function () {
customPopups.showConfirm( customPopups.showConfirm(
'이메일 인증을 취소하고 메인 페이지로 이동하시겠습니까?', '이메일 인증을 취소하고 메인 페이지로 이동하시겠습니까?',
function() { function () {
location.href = '/'; location.href = '/';
} }
); );
@@ -170,7 +172,7 @@
} }
// 인증코드 받기 버튼 클릭 // 인증코드 받기 버튼 클릭
$('.btn_send_code').on('click', function(e) { $('.btn_send_code').on('click', function (e) {
e.preventDefault(); e.preventDefault();
const email = $('#email').val(); const email = $('#email').val();
@@ -186,7 +188,7 @@
headers: { headers: {
[csrfHeaderName]: csrfToken [csrfHeaderName]: csrfToken
}, },
success: function(response) { success: function (response) {
// 테스트 환경 인증번호 표시 // 테스트 환경 인증번호 표시
if (response.authNumber) { if (response.authNumber) {
displayTestAuthNotice(response.authNumber); displayTestAuthNotice(response.authNumber);
@@ -199,7 +201,7 @@
startTimer(300); // 5분 타이머 startTimer(300); // 5분 타이머
startResendTimer(); // 재발송 타이머 시작 (1분) startResendTimer(); // 재발송 타이머 시작 (1분)
}, },
error: function(xhr) { error: function (xhr) {
const errorMsg = xhr.responseJSON?.error || '인증코드 발송에 실패했습니다.'; const errorMsg = xhr.responseJSON?.error || '인증코드 발송에 실패했습니다.';
customPopups.showAlert(errorMsg); customPopups.showAlert(errorMsg);
$button.prop('disabled', false).text('인증코드 받기'); $button.prop('disabled', false).text('인증코드 받기');
@@ -208,7 +210,7 @@
}); });
// 인증코드 확인 버튼 클릭 // 인증코드 확인 버튼 클릭
$('.btn_verify_code').on('click', function(e) { $('.btn_verify_code').on('click', function (e) {
e.preventDefault(); e.preventDefault();
const authCode = $('#authCode').val().trim(); const authCode = $('#authCode').val().trim();
@@ -235,7 +237,7 @@
headers: { headers: {
[csrfHeaderName]: csrfToken [csrfHeaderName]: csrfToken
}, },
success: function(response) { success: function (response) {
clearInterval(timerInterval); clearInterval(timerInterval);
clearInterval(resendTimerInterval); clearInterval(resendTimerInterval);
isVerified = true; isVerified = true;
@@ -249,11 +251,11 @@
customPopups.showAlert('이메일 인증이 완료되었습니다!'); customPopups.showAlert('이메일 인증이 완료되었습니다!');
// 알림 확인 후 메인 페이지로 이동 // 알림 확인 후 메인 페이지로 이동
setTimeout(function() { setTimeout(function () {
location.href = '/'; location.href = '/';
}, 2000); }, 2000);
}, },
error: function(xhr) { error: function (xhr) {
const errorMsg = xhr.responseJSON?.error || '인증코드가 일치하지 않습니다.'; const errorMsg = xhr.responseJSON?.error || '인증코드가 일치하지 않습니다.';
customPopups.showAlert(errorMsg); customPopups.showAlert(errorMsg);
} }
@@ -261,11 +263,12 @@
}); });
// 인증코드 입력 필드 숫자만 허용 // 인증코드 입력 필드 숫자만 허용
$('#authCode').on('input', function() { $('#authCode').on('input', function () {
$(this).val($(this).val().replace(/[^0-9]/g, '')); $(this).val($(this).val().replace(/[^0-9]/g, ''));
}); });
}); });
</script> </script>
</th:block> </th:block>
</html> </html>
@@ -1,39 +1,36 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org" <html lang="ko" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kbank_base_layout}"> layout:decorate="~{layout/kbank_base_layout}">
<body> <body>
<th:block th:fragment="commonUserInfo"> <th:block th:fragment="commonUserInfo">
<div class="org-form-group"> <div class="org-form-group">
<label class="org-form-label"> <label class="org-form-label">
이메일 아이디 <span class="required-badge">필수</span> 이메일 아이디 <span class="required-badge">필수</span>
</label> </label>
<div class="org-form-input-wrapper"> <div class="org-form-input-wrapper">
<div class="org-input-row"> <div class="org-input-row">
<div class="org-compound-input"> <div class="org-compound-input email-input-group">
<input type="text" id="userId" class="org-form-input" <input type="text" id="userId" class="org-form-input"
th:classappend="${isInvited ? 'input-readonly' : ''}" th:classappend="${isInvited ? 'input-readonly' : ''}" maxlength="30" th:value="${emailId}"
maxlength="30" th:readonly="${isInvited}" th:placeholder="#{portalUser.Register.email}">
th:value="${emailId}"
th:readonly="${isInvited}"
th:placeholder="#{portalUser.Register.email}">
<span class="separator">@</span> <span class="separator">@</span>
<input type="text" id="domain" class="org-form-input" <input type="text" id="domain" class="org-form-input"
th:classappend="${isInvited ? 'input-readonly' : ''}" th:classappend="${isInvited ? 'input-readonly' : ''}" maxlength="50" th:value="${domain}"
maxlength="50" th:readonly="${isInvited}" th:placeholder="#{portalUser.Register.domain}">
th:value="${domain}"
th:readonly="${isInvited}"
th:placeholder="#{portalUser.Register.domain}">
</div> </div>
<button type="button" class="btn org-btn-check btn_check_email" <button type="button" class="btn-action-primary md" th:classappend="${isInvited ? 'hidden' : ''}">
th:classappend="${isInvited ? 'hidden' : ''}">
중복체크 중복체크
</button> </button>
</div> </div>
<input type="hidden" id="loginId" name="loginId" th:value="${loginId}"> <input type="hidden" id="loginId" name="loginId" th:value="${loginId}">
<input type="hidden" name="registrationType" th:value="${registrationType}"/> <input type="hidden" name="registrationType" th:value="${registrationType}" />
<input type="hidden" id="registrationScenario" name="registrationScenario" value="new"/> <input type="hidden" id="registrationScenario" name="registrationScenario" value="new" />
<div id="email-validation" class="org-validation-message"></div> <div id="email-validation" class="org-validation-message"></div>
</div> <div id="emailTestNotice" class="test-env-notice"
style="display: none; margin-top: 8px; padding: 10px 12px; background-color: #FFF4E6; border: 1px solid #FFB84D; border-radius: 4px; font-size: 13px; color: #5D4037; line-height: 1.5;"></div>
<input type="hidden" id="emailVerified" name="emailVerified" value="false"/>
<div id="email-auth-validation" class="org-validation-message"></div>
</div> </div>
<!-- 이메일 인증 (중복체크 통과 후 노출) --> <!-- 이메일 인증 (중복체크 통과 후 노출) -->
@@ -43,24 +40,20 @@
</label> </label>
<div class="org-form-input-wrapper"> <div class="org-form-input-wrapper">
<div class="org-input-row"> <div class="org-input-row">
<button type="button" class="btn org-btn-check" id="btnSendEmailCode">인증번호 발송</button> <button type="button" class="btn-action-primary md" id="btnSendEmailCode">인증번호 발송</button>
</div> </div>
<div class="org-input-row" id="emailCodeRow" style="display: none; margin-top: 8px;"> <div class="org-input-row" id="emailCodeRow" style="display: none; margin-top: 8px;">
<div class="org-compound-input" style="position: relative; flex: 1;"> <div class="org-compound-input" style="position: relative; flex: 1;">
<input type="text" id="emailAuthCode" class="org-form-input" maxlength="6" <input type="text" id="emailAuthCode" class="org-form-input" maxlength="6" inputmode="numeric"
inputmode="numeric" autocomplete="one-time-code" placeholder="인증번호 6자리"> autocomplete="one-time-code" placeholder="인증번호 6자리">
<span class="org-timer" id="emailCertifyTime" <span class="org-timer" id="emailCertifyTime"
style="position: absolute; right: 12px; top: 50%; transform: translateY(-50%); color: #E11D48;">03:00</span> style="position: absolute; right: 12px; top: 50%; transform: translateY(-50%); color: #E11D48;">03:00</span>
</div> </div>
<button type="button" class="btn org-btn-check" id="btnVerifyEmailCode">인증확인</button> <button type="button" class="btn-action-primary md" id="btnVerifyEmailCode">인증확인</button>
</div>
<div id="emailTestNotice" class="test-env-notice"
style="display: none; margin-top: 8px; padding: 10px 12px; background-color: #FFF4E6; border: 1px solid #FFB84D; border-radius: 4px; font-size: 13px; color: #5D4037; line-height: 1.5;"></div>
<input type="hidden" id="emailVerified" name="emailVerified" value="false"/>
<div id="email-auth-validation" class="org-validation-message"></div>
</div> </div>
</div> </div>
</th:block> </div>
</th:block>
</body> </body>
<th:block> <th:block>
<script th:fragment="commonUserInfoScript" th:inline="javascript"> <script th:fragment="commonUserInfoScript" th:inline="javascript">
@@ -197,7 +190,12 @@
function setEmailAuthMsg(msg, isError) { function setEmailAuthMsg(msg, isError) {
var el = $('#email-auth-validation'); var el = $('#email-auth-validation');
el.text(msg || ''); el.text(msg || '');
el.css('color', isError ? '#E11D48' : '#0049B4'); if (msg) {
el.removeClass('success error').addClass(isError ? 'error' : 'success');
el.css('display', 'block');
} else {
el.css('display', 'none');
}
} }
function startEmailCodeTimer() { function startEmailCodeTimer() {
@@ -292,4 +290,5 @@
</script> </script>
</th:block> </th:block>
</html> </html>
@@ -1,8 +1,9 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ko" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" <html lang="ko" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kbank_base_layout}"> xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kbank_base_layout}">
<body> <body>
<th:block th:fragment="newUserForm"> <th:block th:fragment="newUserForm">
<div id="newUserFields"> <div id="newUserFields">
<!-- 성명 --> <!-- 성명 -->
<div class="org-form-group"> <div class="org-form-group">
@@ -23,7 +24,7 @@
</label> </label>
<div class="org-form-input-wrapper"> <div class="org-form-input-wrapper">
<div class="org-input-row"> <div class="org-input-row">
<div class="org-compound-input"> <div class="org-compound-input phone-input-group">
<select class="org-form-select phone-number" id="phoneMobile"> <select class="org-form-select phone-number" id="phoneMobile">
<option value="">선택</option> <option value="">선택</option>
<option value="010">010</option> <option value="010">010</option>
@@ -39,7 +40,7 @@
<input type="text" id="cellPhone2" maxlength="4" class="org-form-input phone-number" <input type="text" id="cellPhone2" maxlength="4" class="org-form-input phone-number"
th:placeholder="#{portal.Register.phone2}"> th:placeholder="#{portal.Register.phone2}">
</div> </div>
<button type="button" class="btn org-btn-check btn_auth"> <button type="button" class="btn btn-action-primary md btn_auth">
인증번호 받기 인증번호 받기
</button> </button>
</div> </div>
@@ -57,7 +58,8 @@
<div class="org-input-row"> <div class="org-input-row">
<div class="org-auth-input-group"> <div class="org-auth-input-group">
<input type="text" th:field="*{authNumber}" id="authNumber" maxlength="6" pattern="[0-9]*" <input type="text" th:field="*{authNumber}" id="authNumber" maxlength="6" pattern="[0-9]*"
inputmode="numeric" class="org-form-input" th:placeholder="#{ncrdRegister.validate.authNumber}" required> inputmode="numeric" class="org-form-input" th:placeholder="#{ncrdRegister.validate.authNumber}"
required>
<span class="org-timer" id="certify_time">03:00</span> <span class="org-timer" id="certify_time">03:00</span>
</div> </div>
<button type="button" class="btn org-btn-check btn_verify_auth"> <button type="button" class="btn org-btn-check btn_verify_auth">
@@ -76,18 +78,29 @@
<div class="org-form-input-wrapper"> <div class="org-form-input-wrapper">
<input type="password" name="password" id="password" class="org-form-input" <input type="password" name="password" id="password" class="org-form-input"
th:placeholder="#{portalUser.Register.pass}"> th:placeholder="#{portalUser.Register.pass}">
<input type="hidden" name="isPasswordValid" id="isPasswordValid"/> <input type="hidden" name="isPasswordValid" id="isPasswordValid" />
<div id="password-validation" class="org-validation-message"></div> <div id="password-validation" class="org-validation-message"></div>
<ul class="password-policy-checklist" data-password-input="password"> <ul class="password-policy-checklist" data-password-input="password"
<li data-rule="length" class="is-idle"><span class="policy-icon"></span><span class="policy-text">영문/숫자/특수문자 포함 8~50자</span></li> data-context-loginid="loginId" data-context-mobile="mobileNumber">
<li data-rule="letter" class="is-idle"><span class="policy-icon"></span><span class="policy-text">영문 포함</span></li> <li data-rule="length" class="is-idle"><span class="policy-icon"></span><span class="policy-text">영문/숫자/특수문자
<li data-rule="digit" class="is-idle"><span class="policy-icon"></span><span class="policy-text">숫자 포함</span></li> 포함 8~50자</span></li>
<li data-rule="special" class="is-idle"><span class="policy-icon"></span><span class="policy-text">특수문자 포함</span></li> <li data-rule="letter" class="is-idle"><span class="policy-icon"></span><span class="policy-text">영문
<li data-rule="nospace" class="is-idle"><span class="policy-icon"></span><span class="policy-text">공백 사용 불가</span></li> 포함</span></li>
<li data-rule="norepeat" class="is-idle"><span class="policy-icon"></span><span class="policy-text">동일 문자 3자리 이상 반복 불가</span></li> <li data-rule="digit" class="is-idle"><span class="policy-icon"></span><span class="policy-text">숫자
<li data-rule="noseq" class="is-idle"><span class="policy-icon"></span><span class="policy-text">연속된 문자/숫자 3자리 이상 불가</span></li> 포함</span></li>
<li data-rule="special" class="is-idle"><span class="policy-icon"></span><span class="policy-text">특수문자
포함</span></li>
<li data-rule="nospace" class="is-idle"><span class="policy-icon"></span><span class="policy-text">공백 사용
불가</span></li>
<li data-rule="norepeat" class="is-idle"><span class="policy-icon"></span><span class="policy-text">동일 문자
3자리 이상 반복 불가</span></li>
<li data-rule="noseq" class="is-idle"><span class="policy-icon"></span><span class="policy-text">연속된 문자/숫자
3자리 이상 불가</span></li>
<li data-rule="noid" class="is-idle"><span class="policy-icon"></span><span class="policy-text">아이디(이메일)
포함 불가</span></li>
<li data-rule="nomobile" class="is-idle"><span class="policy-icon"></span><span class="policy-text">휴대전화 번호
포함 불가</span></li>
</ul> </ul>
<p class="password-policy-note">아이디, 휴대전화 번호는 비밀번호에 사용할 수 없습니다.</p>
</div> </div>
</div> </div>
@@ -99,13 +112,13 @@
<div class="org-form-input-wrapper"> <div class="org-form-input-wrapper">
<input type="password" name="password2" id="password2" class="org-form-input" <input type="password" name="password2" id="password2" class="org-form-input"
th:placeholder="#{portalUser.Register.passConfirm}"> th:placeholder="#{portalUser.Register.passConfirm}">
<input type="hidden" name="isPasswordMatch" id="isPasswordMatch"/> <input type="hidden" name="isPasswordMatch" id="isPasswordMatch" />
<div id="password-match-validation" class="org-validation-message"></div> <div id="password-match-validation" class="org-validation-message"></div>
</div> </div>
</div> </div>
</div> </div>
</th:block> </th:block>
</body> </body>
<th:block> <th:block>
<script th:fragment="newUserScript"> <script th:fragment="newUserScript">
@@ -132,15 +145,19 @@
if (mobileNumberInput) { if (mobileNumberInput) {
mobileNumberInput.value = formattedNumber; mobileNumberInput.value = formattedNumber;
} }
// hidden 값 변경은 input 이벤트가 없으므로 체크리스트(nomobile) 수동 재판정
if (window.PasswordPolicy) {
PasswordPolicy.refresh();
}
return formattedNumber; return formattedNumber;
} }
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function () {
let countdownInterval; let countdownInterval;
let isAuthVerified = false; let isAuthVerified = false;
// 휴대폰 번호 중간, 끝자리 입력 제한 및 형식화 // 휴대폰 번호 중간, 끝자리 입력 제한 및 형식화
$('#cellPhone1').on('input', function() { $('#cellPhone1').on('input', function () {
let value = $(this).val().replace(/[^0-9]/g, ''); let value = $(this).val().replace(/[^0-9]/g, '');
if (value.length > 4) { if (value.length > 4) {
value = value.slice(0, 4); value = value.slice(0, 4);
@@ -149,7 +166,7 @@
combineMobileNumber(); combineMobileNumber();
}); });
$('#cellPhone2').on('input', function() { $('#cellPhone2').on('input', function () {
let value = $(this).val().replace(/[^0-9]/g, ''); let value = $(this).val().replace(/[^0-9]/g, '');
if (value.length > 4) { if (value.length > 4) {
value = value.slice(0, 4); value = value.slice(0, 4);
@@ -159,12 +176,12 @@
}); });
// select 변경 감지 // select 변경 감지
$('#phoneMobile').on('change', function() { $('#phoneMobile').on('change', function () {
combineMobileNumber(); combineMobileNumber();
}); });
// 인증번호 입력 제한 // 인증번호 입력 제한
$('#authNumber').on('input', function() { $('#authNumber').on('input', function () {
$(this).val($(this).val().replace(/[^0-9]/g, '')); $(this).val($(this).val().replace(/[^0-9]/g, ''));
}); });
@@ -299,7 +316,7 @@
let authNumberInput = document.getElementById('authNumber'); let authNumberInput = document.getElementById('authNumber');
if (authNumberInput) { if (authNumberInput) {
authNumberInput.addEventListener('input', function() { authNumberInput.addEventListener('input', function () {
this.value = this.value.replace(/\D/g, ''); this.value = this.value.replace(/\D/g, '');
this.classList.toggle('is-invalid', this.value.length !== 6); this.classList.toggle('is-invalid', this.value.length !== 6);
}); });
@@ -307,13 +324,13 @@
let userNameInput = document.getElementById('userName'); let userNameInput = document.getElementById('userName');
if (userNameInput) { if (userNameInput) {
userNameInput.addEventListener('input', function() { userNameInput.addEventListener('input', function () {
this.classList.toggle('is-invalid', this.value.trim().length === 0); this.classList.toggle('is-invalid', this.value.trim().length === 0);
}); });
} }
// 인증번호 요청 버튼 이벤트 리스너 // 인증번호 요청 버튼 이벤트 리스너
$('.btn_auth').on('click', function() { $('.btn_auth').on('click', function () {
let mobileNumber = combineMobileNumber(); let mobileNumber = combineMobileNumber();
if (!mobileNumber) { if (!mobileNumber) {
customPopups.showAlert('휴대폰 번호를 올바르게 입력해주세요.'); customPopups.showAlert('휴대폰 번호를 올바르게 입력해주세요.');
@@ -330,17 +347,17 @@
purpose: 'signup', purpose: 'signup',
_csrf: $('input[name="_csrf"]').val() _csrf: $('input[name="_csrf"]').val()
}, },
success: function(response) { success: function (response) {
handleAuthNumberRequest(response); handleAuthNumberRequest(response);
}, },
error: function() { error: function () {
customPopups.showAlert('처리 중 오류가 발생했습니다. 다시 시도해주세요.'); customPopups.showAlert('처리 중 오류가 발생했습니다. 다시 시도해주세요.');
} }
}); });
}); });
// 인증번호 확인 버튼 이벤트 리스너 // 인증번호 확인 버튼 이벤트 리스너
$('.btn_verify_auth').on('click', function() { $('.btn_verify_auth').on('click', function () {
let authNumber = $('#authNumber').val(); let authNumber = $('#authNumber').val();
if (authNumber.length !== 6) { if (authNumber.length !== 6) {
customPopups.showAlert('6자리 인증번호를 입력해주세요.'); customPopups.showAlert('6자리 인증번호를 입력해주세요.');
@@ -355,17 +372,17 @@
authNumber: authNumber, authNumber: authNumber,
_csrf: $('input[name="_csrf"]').val() _csrf: $('input[name="_csrf"]').val()
}, },
success: function(response) { success: function (response) {
handleAuthNumberVerification(response); handleAuthNumberVerification(response);
}, },
error: function() { error: function () {
customPopups.showAlert('처리 중 오류가 발생했습니다. 다시 시도해주세요.'); customPopups.showAlert('처리 중 오류가 발생했습니다. 다시 시도해주세요.');
} }
}); });
}); });
// 비밀번호 검증 // 비밀번호 검증
$('#password').on('blur', function() { $('#password').on('blur', function () {
let password = $(this).val(); let password = $(this).val();
if (!password) { if (!password) {
return; return;
@@ -380,21 +397,21 @@
loginId: $('#loginId').val(), loginId: $('#loginId').val(),
_csrf: $('input[name="_csrf"]').val() _csrf: $('input[name="_csrf"]').val()
}, },
success: function(response) { success: function (response) {
let targetElement = $('#password-validation'); let targetElement = $('#password-validation');
targetElement.removeClass('success error'); targetElement.removeClass('success error');
targetElement.addClass(response.valid ? 'success' : 'error'); targetElement.addClass(response.valid ? 'success' : 'error');
targetElement.text(response.message); targetElement.text(response.message);
$('#isPasswordValid').val(response.valid); $('#isPasswordValid').val(response.valid);
}, },
error: function() { error: function () {
customPopups.showAlert('처리 중 오류가 발생했습니다. 다시 시도해주세요.'); customPopups.showAlert('처리 중 오류가 발생했습니다. 다시 시도해주세요.');
} }
}); });
}); });
// 비밀번호 확인 검증 // 비밀번호 확인 검증
$('#password2').on('blur', function() { $('#password2').on('blur', function () {
let password2 = $(this).val(); let password2 = $(this).val();
if (!password2) { if (!password2) {
return; return;
@@ -408,14 +425,14 @@
password2: password2, password2: password2,
_csrf: $('input[name="_csrf"]').val() _csrf: $('input[name="_csrf"]').val()
}, },
success: function(response) { success: function (response) {
let targetElement = $('#password-match-validation'); let targetElement = $('#password-match-validation');
targetElement.removeClass('success error'); targetElement.removeClass('success error');
targetElement.addClass(response.valid ? 'success' : 'error'); targetElement.addClass(response.valid ? 'success' : 'error');
targetElement.text(response.message); targetElement.text(response.message);
$('#isPasswordMatch').val(response.valid); $('#isPasswordMatch').val(response.valid);
}, },
error: function() { error: function () {
customPopups.showAlert("처리 중 오류가 발생했습니다. 다시 시도해주세요."); customPopups.showAlert("처리 중 오류가 발생했습니다. 다시 시도해주세요.");
} }
}); });
@@ -423,4 +440,5 @@
}); });
</script> </script>
</th:block> </th:block>
</html> </html>
@@ -24,8 +24,18 @@
<!-- Alert Container --> <!-- Alert Container -->
<div id="alertContainer"></div> <div id="alertContainer"></div>
<div class="org-section-header" style="margin-top: 60px;margin-bottom: 60px;"> <!-- Registration Card Wrapper -->
<h3>법인 회원 가입 후 서비스 또는 API 이용을 하실 수 있습니다.</h3> <div class="register-card-wrapper">
<!-- Info Notice -->
<div class="org-info-notice">
<div class="notice-icon-wrapper">
<svg class="notice-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</div>
<p class="notice-text">법인 회원 가입 후 서비스 또는 API 이용을 하실 수 있습니다.</p>
</div> </div>
<!-- Agreement Section --> <!-- Agreement Section -->
@@ -48,9 +58,14 @@
</div> </div>
<div class="org-info-notice"> <div class="org-info-notice">
<ul> <div class="notice-icon-wrapper">
<li>법인관리자는 API 신청 및 개발자 초대 등 모든 권한을 보유합니다.</li> <svg class="notice-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</ul> <circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</div>
<p class="notice-text">법인관리자는 API 신청 및 개발자 초대 등 모든 권한을 보유합니다.</p>
</div> </div>
<th:block th:if="${registrationType == 'corporate'}"> <th:block th:if="${registrationType == 'corporate'}">
@@ -63,6 +78,7 @@
<button type="button" class="btn btn-primary btn_register">가입신청</button> <button type="button" class="btn btn-primary btn_register">가입신청</button>
</div> </div>
</form> </form>
</div>
<!-- Loading Overlay --> <!-- Loading Overlay -->
<div class="org-loading-overlay" id="loadingOverlay"> <div class="org-loading-overlay" id="loadingOverlay">
@@ -1,65 +1,80 @@
<!DOCTYPE html> <!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" <html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_base_layout}">
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/djbank_base_layout}">
<body> <body>
<th:block layout:fragment="contentFragment"> <th:block layout:fragment="contentFragment">
<div class="signup-selection-page"> <div class="signup-selection-page">
<div class="signup-selection-container"> <div class="signup-selection-container">
<div class="signup-selection-card"> <div class="signup-selection-card">
<!-- Logo --> <!-- Header Title -->
<div class="signup-logo"> <div class="signup-header">
<img th:src="@{/img/logo/logo_box.png}" alt="DJBank"> <div class="signup-title-row">
<svg class="signup-title-icon" width="35" height="35" viewBox="0 0 24 24" fill="currentColor"
xmlns="http://www.w3.org/2000/svg">
<path
d="M9 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4zm11-4h-2v2h-2v2h2v2h2v-2h2v-2h-2v-2z" />
</svg>
<h2 class="signup-title">회원가입</h2>
</div>
<p class="signup-message">DJBank API Portal 사용을 위해 회원 가입해 주세요.</p>
</div> </div>
<!-- Message --> <!-- Signup Cards -->
<p class="signup-message">DJBank API Portal 사용을 위해 회원 가입해 주세요.</p> <div class="signup-cards">
<!-- Individual Signup Card -->
<!-- Signup Buttons --> <a th:href="@{/signup/portalUser}" class="signup-card-item individual-card">
<div class="signup-buttons"> <div class="card-icon-wrapper">
<!-- Individual Signup Button --> <svg width="36" height="36" viewBox="0 0 22 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<a th:href="@{/signup/portalUser}" class="signup-btn signup-btn-individual">
<svg width="22" height="30" viewBox="0 0 22 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path <path
d="M15.4287 12.748C19.0578 12.7481 21.9998 15.7302 22 19.4092V29.3447C21.9998 29.6617 21.7783 29.9263 21.4834 29.9873L21.3535 30H0.646484C0.289425 29.9998 0.00024463 29.7067 0 29.3447V18.2607C0 15.2164 2.43446 12.748 5.4375 12.748C6.67211 12.7481 7.86991 13.1742 8.83398 13.9561L9.13965 14.2031C9.4187 14.4294 9.46443 14.8431 9.24121 15.126C9.01802 15.4088 8.61011 15.4546 8.33105 15.2285L8.02539 14.9805C7.29081 14.3848 6.37819 14.0596 5.4375 14.0596C3.1492 14.0596 1.29395 15.9409 1.29395 18.2607V28.6885H20.7061V19.4092C20.7058 16.4548 18.3431 14.0596 15.4287 14.0596C13.2292 14.0598 11.2606 15.4434 10.4883 17.5312L9.98828 18.8818C9.86275 19.2209 9.48978 19.3927 9.15527 19.2656C8.82079 19.1383 8.65091 18.7601 8.77637 18.4209L9.27637 17.0703C10.2382 14.4705 12.6898 12.7482 15.4287 12.748ZM11 0C14.2162 8.65396e-06 16.8231 2.64285 16.8232 5.90332C16.8232 9.16394 14.2163 11.8076 11 11.8076C7.78365 11.8076 5.17676 9.16394 5.17676 5.90332C5.17693 2.64284 7.78375 0 11 0ZM11 1.31152C8.4985 1.31152 6.47087 3.36743 6.4707 5.90332C6.4707 8.43936 8.49839 10.4951 11 10.4951C13.5016 10.4951 15.5293 8.43935 15.5293 5.90332C15.5291 3.36743 13.5015 1.31153 11 1.31152Z" d="M15.4287 12.748C19.0578 12.7481 21.9998 15.7302 22 19.4092V29.3447C21.9998 29.6617 21.7783 29.9263 21.4834 29.9873L21.3535 30H0.646484C0.289425 29.9998 0.00024463 29.7067 0 29.3447V18.2607C0 15.2164 2.43446 12.748 5.4375 12.748C6.67211 12.7481 7.86991 13.1742 8.83398 13.9561L9.13965 14.2031C9.4187 14.4294 9.46443 14.8431 9.24121 15.126C9.01802 15.4088 8.61011 15.4546 8.33105 15.2285L8.02539 14.9805C7.29081 14.3848 6.37819 14.0596 5.4375 14.0596C3.1492 14.0596 1.29395 15.9409 1.29395 18.2607V28.6885H20.7061V19.4092C20.7058 16.4548 18.3431 14.0596 15.4287 14.0596C13.2292 14.0598 11.2606 15.4434 10.4883 17.5312L9.98828 18.8818C9.86275 19.2209 9.48978 19.3927 9.15527 19.2656C8.82079 19.1383 8.65091 18.7601 8.77637 18.4209L9.27637 17.0703C10.2382 14.4705 12.6898 12.7482 15.4287 12.748ZM11 0C14.2162 8.65396e-06 16.8231 2.64285 16.8232 5.90332C16.8232 9.16394 14.2163 11.8076 11 11.8076C7.78365 11.8076 5.17676 9.16394 5.17676 5.90332C5.17693 2.64284 7.78375 0 11 0ZM11 1.31152C8.4985 1.31152 6.47087 3.36743 6.4707 5.90332C6.4707 8.43936 8.49839 10.4951 11 10.4951C13.5016 10.4951 15.5293 8.43935 15.5293 5.90332C15.5291 3.36743 13.5015 1.31153 11 1.31152Z"
fill="white"/> fill="currentColor" />
</svg> </svg>
<span>개인회원 가입</span> </div>
<div class="card-text-wrapper">
<h3 class="card-title">개인 회원가입</h3>
<p class="card-desc">개인 개발자 및 API 연동 테스트를 원하시는 사용자를 위한 회원가입입니다.</p>
</div>
</a> </a>
<!-- Organization Signup Button -->
<a th:href="@{/signup/portalOrg}" class="signup-btn signup-btn-organization"> <!-- Organization Signup Card -->
<svg width="26" height="30" viewBox="0 0 26 30" fill="none" xmlns="http://www.w3.org/2000/svg"> <a th:href="@{/signup/portalOrg}" class="signup-card-item organization-card">
<div class="card-icon-wrapper">
<svg width="36" height="36" viewBox="0 0 26 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path <path
d="M13.001 7.2002C14.3062 7.20046 15.3652 8.27529 15.3652 9.60059V27.5996L15.3623 27.7236C15.3009 28.9505 14.3313 29.9346 13.123 29.9971L13.001 30H2.36426L2.24316 29.9971C1.03469 29.9348 0.0653104 28.9506 0.00390625 27.7236L0 27.5996V9.60059C1.90336e-07 8.27527 1.05901 7.20043 2.36426 7.2002H13.001ZM23.0469 0C24.6785 0.00011158 26.0009 1.3433 26.001 3V27C26.0009 28.6567 24.6785 29.9999 23.0469 30H16.5469C16.2205 30 15.9551 29.7308 15.9551 29.3994C15.9553 29.0682 16.2206 28.7998 16.5469 28.7998H23.0469C24.0258 28.7997 24.8192 27.9939 24.8193 27V3C24.8193 2.00604 24.0258 1.20031 23.0469 1.2002H13.5918C12.6128 1.20022 11.8194 2.00598 11.8193 3V6C11.8193 6.33124 11.5547 6.5994 11.2285 6.59961C10.9022 6.59961 10.6377 6.33137 10.6377 6V3C10.6378 1.34325 11.9601 2.44626e-05 13.5918 0H23.0469ZM2.36426 8.40039C1.71173 8.40062 1.18262 8.938 1.18262 9.60059V27.5996C1.18265 28.2622 1.71175 28.7996 2.36426 28.7998H4.1377V25.2002C4.1377 23.5435 5.46013 22.2004 7.0918 22.2002H8.86523C10.4966 22.2007 11.8193 23.5437 11.8193 25.2002V28.7998H13.001C13.6535 28.7995 14.1836 28.2621 14.1836 27.5996V9.60059C14.1836 8.93802 13.6535 8.40066 13.001 8.40039H2.36426ZM7.0918 23.4004C6.11285 23.4005 5.31934 24.2062 5.31934 25.2002V28.7998H10.6377V25.2002C10.6377 24.2064 9.84392 23.4009 8.86523 23.4004H7.0918ZM18.3193 24.5996H17.1377V22.2002H18.3193V24.5996ZM21.8652 24.5996H20.6836V22.2002H21.8652V24.5996ZM18.3193 20.4004H17.1377V18H18.3193V20.4004ZM21.8652 20.4004H20.6836V18H21.8652V20.4004ZM4.72852 18.5996H3.54688V16.2002H4.72852V18.5996ZM8.27441 18.5996H7.09277V16.2002H8.27441V18.5996ZM11.8193 18.5996H10.6377V16.2002H11.8193V18.5996ZM18.3193 16.2002H17.1377V13.7998H18.3193V16.2002ZM21.8652 16.2002H20.6836V13.7998H21.8652V16.2002ZM4.72852 14.4004H3.54688V12H4.72852V14.4004ZM8.27441 14.4004H7.09277V12H8.27441V14.4004ZM11.8193 14.4004H10.6377V12H11.8193V14.4004ZM18.3193 12H17.1377V9.60059H18.3193V12ZM21.8652 12H20.6836V9.60059H21.8652V12ZM18.3193 7.7998H17.1377V5.40039H18.3193V7.7998ZM21.8652 7.7998H20.6836V5.40039H21.8652V7.7998Z" d="M13.001 7.2002C14.3062 7.20046 15.3652 8.27529 15.3652 9.60059V27.5996L15.3623 27.7236C15.3009 28.9505 14.3313 29.9346 13.123 29.9971L13.001 30H2.36426L2.24316 29.9971C1.03469 29.9348 0.0653104 28.9506 0.00390625 27.7236L0 27.5996V9.60059C1.90336e-07 8.27527 1.05901 7.20043 2.36426 7.2002H13.001ZM23.0469 0C24.6785 0.00011158 26.0009 1.3433 26.001 3V27C26.0009 28.6567 24.6785 29.9999 23.0469 30H16.5469C16.2205 30 15.9551 29.7308 15.9551 29.3994C15.9553 29.0682 16.2206 28.7998 16.5469 28.7998H23.0469C24.0258 28.7997 24.8192 27.9939 24.8193 27V3C24.8193 2.00604 24.0258 1.20031 23.0469 1.2002H13.5918C12.6128 1.20022 11.8194 2.00598 11.8193 3V6C11.8193 6.33124 11.5547 6.5994 11.2285 6.59961C10.9022 6.59961 10.6377 6.33137 10.6377 6V3C10.6378 1.34325 11.9601 2.44626e-05 13.5918 0H23.0469ZM2.36426 8.40039C1.71173 8.40062 1.18262 8.938 1.18262 9.60059V27.5996C1.18265 28.2622 1.71175 28.7996 2.36426 28.7998H4.1377V25.2002C4.1377 23.5435 5.46013 22.2004 7.0918 22.2002H8.86523C10.4966 22.2007 11.8193 23.5437 11.8193 25.2002V28.7998H13.001C13.6535 28.7995 14.1836 28.2621 14.1836 27.5996V9.60059C14.1836 8.93802 13.6535 8.40066 13.001 8.40039H2.36426ZM7.0918 23.4004C6.11285 23.4005 5.31934 24.2062 5.31934 25.2002V28.7998H10.6377V25.2002C10.6377 24.2064 9.84392 23.4009 8.86523 23.4004H7.0918ZM18.3193 24.5996H17.1377V22.2002H18.3193V24.5996ZM21.8652 24.5996H20.6836V22.2002H21.8652V24.5996ZM18.3193 20.4004H17.1377V18H18.3193V20.4004ZM21.8652 20.4004H20.6836V18H21.8652V20.4004ZM4.72852 18.5996H3.54688V16.2002H4.72852V18.5996ZM8.27441 18.5996H7.09277V16.2002H8.27441V18.5996ZM11.8193 18.5996H10.6377V16.2002H11.8193V18.5996ZM18.3193 16.2002H17.1377V13.7998H18.3193V16.2002ZM21.8652 16.2002H20.6836V13.7998H21.8652V16.2002ZM4.72852 14.4004H3.54688V12H4.72852V14.4004ZM8.27441 14.4004H7.09277V12H8.27441V14.4004ZM11.8193 14.4004H10.6377V12H11.8193V14.4004ZM18.3193 12H17.1377V9.60059H18.3193V12ZM21.8652 12H20.6836V9.60059H21.8652V12ZM18.3193 7.7998H17.1377V5.40039H18.3193V7.7998ZM21.8652 7.7998H20.6836V5.40039H21.8652V7.7998Z"
fill="white"/> fill="currentColor" />
</svg> </svg>
<span>법인회원 가입</span> </div>
<div class="card-text-wrapper">
<h3 class="card-title">법인 회원가입</h3>
<p class="card-desc">기업 및 기관 파트너로서 정식 API 서비스 연동 및 운영을 위한 회원가입입니다.</p>
</div>
</a> </a>
</div> </div>
<!-- Navigation Links --> <!-- Navigation Links -->
<div class="signup-navigation"> <div class="signup-navigation-box">
<a th:href="@{/}" class="signup-nav-link">홈으로</a> <a th:href="@{/}" class="signup-nav-btn">홈으로</a>
<span class="signup-nav-separator">|</span> <div class="signup-nav-divider"></div>
<a th:href="@{/login}" class="signup-nav-link">로그인 하기</a> <a th:href="@{/login}" class="signup-nav-btn">로그인 하기</a>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</th:block> </th:block>
<th:block layout:fragment="script"> <th:block layout:fragment="script">
<script th:src="@{/js/jquery-3.7.1.min.js}"></script> <script th:src="@{/js/jquery-3.7.1.min.js}"></script>
<script th:src="@{/js/daterangepicker.js}"></script> <script th:src="@{/js/daterangepicker.js}"></script>
<script th:src="@{/js/slick.min.js}"></script> <script th:src="@{/js/slick.min.js}"></script>
<script th:src="@{/js/front2.js}"></script> <script th:src="@{/js/front2.js}"></script>
</th:block> </th:block>
<th:block layout:fragment="contentScript"> <th:block layout:fragment="contentScript">
<script> <script>
// Modern signup selection page - no custom JS needed // Modern signup selection page - no custom JS needed
// Card interactions handled by CSS and standard anchor tags // Card interactions handled by CSS and standard anchor tags
</script> </script>
</th:block> </th:block>
</body> </body>
</html> </html>
@@ -1,79 +1,78 @@
<!DOCTYPE html> <!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" <html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/djbank_title_layout}"> layout:decorate="~{layout/djbank_title_layout}">
<body> <body>
<section layout:fragment="title"> <section layout:fragment="title">
<div class="page-title-banner"> <div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image"> <img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>회원가입</h1> <h1>회원가입</h1>
</div> </div>
</section> </section>
<section layout:fragment="contentFragment"> <section layout:fragment="contentFragment">
<div class="org-register-container"> <div class="service-main">
<div class="app-management-content">
<div class="password-change-wrapper">
<h2 class="page-outer-title">이메일 인증</h2>
<div class="common-title-bar"> <form id="signupVerificationEmailForm" role="form" name="signupVerificationEmailForm" method="post">
<h2 class="common-title">이메일 인증</h2> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
</div>
<div class="register-form-container"> <div class="register-form-container">
<p class="form-hint" style="margin-bottom: 16px;"> <!-- 안내 Notice Box -->
회원가입을 완료하려면 가입하신 이메일 주소로 인증을 진행해 주세요. <div class="info-notice-box" style="margin-bottom: 24px;">
</p> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4685ef" stroke-width="2">
<form id="signupVerificationEmailForm" role="form" name="signupVerificationEmailForm" method="post"> <circle cx="12" cy="12" r="10"></circle>
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/> <path d="M12 16v-4"></path>
<path d="M12 8h.01"></path>
</svg>
<p>회원가입을 완료하려면 가입하신 이메일 주소로 인증을 진행해 주세요.</p>
</div>
<div class="register-form"> <!-- 이메일 주소 행 -->
<!-- 이메일 주소 + 인증코드 받기 버튼 (한 줄) --> <div class="form-row">
<div class="form-row" style="margin-bottom: 0"> <div class="form-label-wrapper">
<div class="form-label-wrapper label-offset">
<span class="form-label-text">이메일 주소</span> <span class="form-label-text">이메일 주소</span>
</div> </div>
<div class="form-field-wrapper input-with-button"> <div class="form-field-wrapper input-with-button">
<input type="email" id="email" name="email" class="form-input input-readonly" <input type="email" id="email" name="email" class="form-input input-readonly" th:value="${email}"
th:value="${email}" readonly disabled="disabled"> readonly disabled="disabled" style="flex: 1;">
<button type="button" class="btn-input-action btn-auth btn_send_code">인증코드 받기</button> <button type="button" class="btn-input-action btn-auth btn_send_code">인증코드 받기</button>
</div> </div>
</div> </div>
<div class="form-row" style="margin-bottom: 0"> <!-- 인증번호 입력 행 (초기 숨김) -->
<div class="form-label-wrapper label-offset">
<span class="form-label-text">&nbsp;</span>
</div>
<div class="form-field-wrapper">
<p class="form-hint" style="margin-top: 0;">위 이메일 주소로 인증코드가 발송됩니다.</p>
</div>
</div>
<!-- 인증번호 입력 -->
<div class="form-row" id="authCodeContainer" style="display: none;"> <div class="form-row" id="authCodeContainer" style="display: none;">
<div class="form-label-wrapper label-offset"> <div class="form-label-wrapper">
<span class="form-label-text">인증코드 입력</span> <span class="required-badge">필수</span> <span class="form-label-text">인증코드 입력</span>
</div> </div>
<div class="form-field-wrapper input-with-button"> <div class="form-field-wrapper input-with-button">
<div class="auth-input-group"> <div class="auth-input-group" style="flex: 1; position: relative;">
<input type="text" id="authCode" name="authCode" maxlength="6" pattern="[0-9]*" <input type="text" id="authCode" name="authCode" maxlength="6" pattern="[0-9]*" inputmode="numeric"
inputmode="numeric" class="form-input" class="form-input" placeholder="이메일로 받은 인증코드 6자리" style="width: 100%; padding-right: 70px;">
placeholder="이메일로 받은 인증코드 6자리"> <span class="auth-timer" id="certify_time"
<span class="auth-timer" id="certify_time">05:00</span> style="position: absolute; right: 16px; top: 50%; transform: translateY(-50%); font-weight: 500; font-size: 13px;">05:00</span>
</div> </div>
<button type="button" class="btn-input-action btn-auth btn_verify_code">인증코드 확인</button> <button type="button" class="btn-input-action btn-auth btn_verify_code">인증코드 확인</button>
</div> </div>
</div> </div>
</div> </div>
</form>
</div>
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="form-actions"> <div class="form-actions" style="justify-content: flex-end; margin-top: 24px;">
<button type="button" class="btn-submit btn-secondary btn_cancel">나중에 인증</button> <div class="right-buttons">
<button type="button" class="btn-cancel btn_cancel">나중에 인증</button>
</div> </div>
</div> </div>
</section> </form>
</div>
</div>
</div>
</section>
</body> </body>
<th:block layout:fragment="contentScript"> <th:block layout:fragment="contentScript">
<script th:inline="javascript"> <script th:inline="javascript">
$(document).ready(function() { $(document).ready(function () {
let timerInterval; let timerInterval;
let resendTimerInterval; let resendTimerInterval;
let isVerified = false; let isVerified = false;
@@ -86,10 +85,10 @@
const resendLimitSeconds = /*[[${@environment.getProperty('portal.auth.resend_limit_seconds', '60')}]]*/ 60; const resendLimitSeconds = /*[[${@environment.getProperty('portal.auth.resend_limit_seconds', '60')}]]*/ 60;
// "나중에 인증" 클릭 시: 가입은 완료된 상태이므로 로그인 페이지로 이동 (로그인 후 인증 유도) // "나중에 인증" 클릭 시: 가입은 완료된 상태이므로 로그인 페이지로 이동 (로그인 후 인증 유도)
$('.btn_cancel').on('click', function() { $('.btn_cancel').on('click', function () {
customPopups.showConfirm( customPopups.showConfirm(
'이메일 인증을 나중에 하시겠습니까? 로그인 후에도 인증을 완료할 수 있습니다.', '이메일 인증을 나중에 하시겠습니까? 로그인 후에도 인증을 완료할 수 있습니다.',
function() { function () {
location.href = /*[[@{/login}]]*/ '/login'; location.href = /*[[@{/login}]]*/ '/login';
} }
); );
@@ -173,7 +172,7 @@
} }
// 인증코드 받기 버튼 클릭 // 인증코드 받기 버튼 클릭
$('.btn_send_code').on('click', function(e) { $('.btn_send_code').on('click', function (e) {
e.preventDefault(); e.preventDefault();
const $button = $(this); const $button = $(this);
@@ -187,7 +186,7 @@
headers: { headers: {
[csrfHeaderName]: csrfToken [csrfHeaderName]: csrfToken
}, },
success: function(response) { success: function (response) {
if (!response.valid) { if (!response.valid) {
customPopups.showAlert(response.message || '인증코드 발송에 실패했습니다.'); customPopups.showAlert(response.message || '인증코드 발송에 실패했습니다.');
$button.prop('disabled', false).text('인증코드 받기'); $button.prop('disabled', false).text('인증코드 받기');
@@ -206,7 +205,7 @@
startTimer(300); // 5분 타이머 startTimer(300); // 5분 타이머
startResendTimer(); // 재발송 타이머 시작 startResendTimer(); // 재발송 타이머 시작
}, },
error: function() { error: function () {
customPopups.showAlert('인증코드 발송에 실패했습니다.'); customPopups.showAlert('인증코드 발송에 실패했습니다.');
$button.prop('disabled', false).text('인증코드 받기'); $button.prop('disabled', false).text('인증코드 받기');
} }
@@ -214,7 +213,7 @@
}); });
// 인증코드 확인 버튼 클릭 // 인증코드 확인 버튼 클릭
$('.btn_verify_code').on('click', function(e) { $('.btn_verify_code').on('click', function (e) {
e.preventDefault(); e.preventDefault();
const authCode = $('#authCode').val().trim(); const authCode = $('#authCode').val().trim();
@@ -237,7 +236,7 @@
headers: { headers: {
[csrfHeaderName]: csrfToken [csrfHeaderName]: csrfToken
}, },
success: function(response) { success: function (response) {
if (!response.valid) { if (!response.valid) {
customPopups.showAlert(response.message || '인증코드가 일치하지 않습니다.'); customPopups.showAlert(response.message || '인증코드가 일치하지 않습니다.');
return; return;
@@ -256,22 +255,23 @@
customPopups.showAlert('이메일 인증이 완료되었습니다! 로그인 후 이용해 주세요.'); customPopups.showAlert('이메일 인증이 완료되었습니다! 로그인 후 이용해 주세요.');
// 알림 확인 후 로그인 페이지로 이동 // 알림 확인 후 로그인 페이지로 이동
setTimeout(function() { setTimeout(function () {
location.href = /*[[@{/login}]]*/ '/login'; location.href = /*[[@{/login}]]*/ '/login';
}, 2000); }, 2000);
}, },
error: function() { error: function () {
customPopups.showAlert('인증코드 확인에 실패했습니다.'); customPopups.showAlert('인증코드 확인에 실패했습니다.');
} }
}); });
}); });
// 인증코드 입력 필드 숫자만 허용 // 인증코드 입력 필드 숫자만 허용
$('#authCode').on('input', function() { $('#authCode').on('input', function () {
$(this).val($(this).val().replace(/[^0-9]/g, '')); $(this).val($(this).val().replace(/[^0-9]/g, ''));
}); });
}); });
</script> </script>
</th:block> </th:block>
</html> </html>
@@ -1,10 +1,9 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ko" xmlns="http://www.w3.org/1999/xhtml" <html lang="ko" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/base_layout}">
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/base_layout}">
<body> <body>
<th:block th:fragment="agreementContent(termsOfUse, privacyCollect)"> <th:block th:fragment="agreementContent(termsOfUse, privacyCollect)">
<!-- Agreement Section Header --> <!-- Agreement Section Header -->
<div class="org-section-header org-section-header--agreement"> <div class="org-section-header org-section-header--agreement">
<h3 th:text="${agreementTitle} ?: '약관 동의'">약관 동의</h3> <h3 th:text="${agreementTitle} ?: '약관 동의'">약관 동의</h3>
@@ -29,7 +28,8 @@
<!-- Terms of Use --> <!-- Terms of Use -->
<div class="agreement-item-row"> <div class="agreement-item-row">
<label class="agreement-checkbox-label"> <label class="agreement-checkbox-label">
<input type="checkbox" name="termsOfUse" id="termsOfUse" class="agreement-checkbox-input" required> <input type="checkbox" name="termsOfUse" id="termsOfUse" class="agreement-checkbox-input"
required disabled>
<span class="agreement-checkbox-custom"></span> <span class="agreement-checkbox-custom"></span>
<span class="agreement-checkbox-text"> <span class="agreement-checkbox-text">
<span class="agreement-required">[필수]</span> <span class="agreement-required">[필수]</span>
@@ -41,15 +41,20 @@
</button> </button>
</div> </div>
<div id="termsOfUseContent" class="agreement-content" style="display:none;"> <div id="termsOfUseContent" class="agreement-content" style="display:none;">
<div class="agreement-scroll"> <div class="agreement-scroll" style="max-height: 400px; overflow-y: auto;">
<div class="editor-content" th:utext="${termsOfUse?.contents ?: '이용약관 내용을 불러올 수 없습니다.'}"></div> <div class="editor-content" th:utext="${termsOfUse?.contents ?: '이용약관 내용을 불러올 수 없습니다.'}"></div>
</div> </div>
<div class="agreement-btn-wrapper" style="text-align: center; margin-top: 15px;">
<button type="button" class="btn-action-primary md agreement-agree-btn"
data-checkbox-id="termsOfUse" disabled>동의</button>
</div>
</div> </div>
<!-- Privacy Policy --> <!-- Privacy Policy -->
<div class="agreement-item-row"> <div class="agreement-item-row">
<label class="agreement-checkbox-label"> <label class="agreement-checkbox-label">
<input type="checkbox" name="privacyCollect" id="privacyCollect" class="agreement-checkbox-input" required> <input type="checkbox" name="privacyCollect" id="privacyCollect"
class="agreement-checkbox-input" required disabled>
<span class="agreement-checkbox-custom"></span> <span class="agreement-checkbox-custom"></span>
<th:block th:switch="${registrationType}"> <th:block th:switch="${registrationType}">
<span class="agreement-checkbox-text" th:case="'personal'"> <span class="agreement-checkbox-text" th:case="'personal'">
@@ -67,8 +72,13 @@
</button> </button>
</div> </div>
<div id="privacyCollectContent" class="agreement-content" style="display:none;"> <div id="privacyCollectContent" class="agreement-content" style="display:none;">
<div class="agreement-scroll"> <div class="agreement-scroll" style="max-height: 400px; overflow-y: auto;">
<div class="editor-content" th:utext="${privacyCollect?.contents ?: '개인정보수집동의서 내용을 불러올 수 없습니다.'}"></div> <div class="editor-content"
th:utext="${privacyCollect?.contents ?: '개인정보수집동의서 내용을 불러올 수 없습니다.'}"></div>
</div>
<div class="agreement-btn-wrapper" style="text-align: center; margin-top: 15px;">
<button type="button" class="btn-action-primary md agreement-agree-btn"
data-checkbox-id="privacyCollect" disabled>동의</button>
</div> </div>
</div> </div>
@@ -76,7 +86,8 @@
<th:block th:if="${notificationConsent != null}"> <th:block th:if="${notificationConsent != null}">
<div class="agreement-item-row"> <div class="agreement-item-row">
<label class="agreement-checkbox-label"> <label class="agreement-checkbox-label">
<input type="checkbox" name="notificationConsent" id="notificationConsent" class="agreement-checkbox-input" required> <input type="checkbox" name="notificationConsent" id="notificationConsent"
class="agreement-checkbox-input" required disabled>
<span class="agreement-checkbox-custom"></span> <span class="agreement-checkbox-custom"></span>
<span class="agreement-checkbox-text"> <span class="agreement-checkbox-text">
<span class="agreement-required">[필수]</span> <span class="agreement-required">[필수]</span>
@@ -88,26 +99,42 @@
</button> </button>
</div> </div>
<div id="notificationConsentContent" class="agreement-content" style="display:none;"> <div id="notificationConsentContent" class="agreement-content" style="display:none;">
<div class="agreement-scroll"> <div class="agreement-scroll" style="max-height: 400px; overflow-y: auto;">
<div class="editor-content" th:utext="${notificationConsent?.contents ?: '알림 수신 동의서 내용을 불러올 수 없습니다.'}"></div> <div class="editor-content"
th:utext="${notificationConsent?.contents ?: '알림 수신 동의서 내용을 불러올 수 없습니다.'}"></div>
</div>
<div class="agreement-btn-wrapper" style="text-align: center; margin-top: 15px;">
<button type="button" class="btn-action-primary md agreement-agree-btn"
data-checkbox-id="notificationConsent" disabled>동의</button>
</div> </div>
</div> </div>
</th:block> </th:block>
</div> </div>
</form> </form>
</th:block> </th:block>
<script th:fragment="agreementScript"> <script th:fragment="agreementScript">
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
const agreeAll = document.getElementById('agree_all'); const agreeAll = document.getElementById('agree_all');
const agreementChecks = document.querySelectorAll('.agreement-checkbox-input[required]'); const agreementChecks = document.querySelectorAll('.agreement-checkbox-input[required]');
const toggleIcons = document.querySelectorAll('.agreement-toggle-icon'); const toggleIcons = document.querySelectorAll('.agreement-toggle-icon');
// 전체 동의 체크박스 이벤트 // 전체동의 클릭 시 아직 다 읽지 않은 약관이 있으면 경고 알림 표시
if (agreeAll) { if (agreeAll) {
agreeAll.addEventListener('click', function (e) {
const hasLocked = Array.from(agreementChecks).some(check => check.disabled);
if (hasLocked) {
e.preventDefault();
this.checked = false;
customPopups.showAlert('모든 약관을 상세히 펼쳐서 끝까지 스크롤하여 읽으신 후에 동의하실 수 있습니다.');
}
});
agreeAll.addEventListener('change', function () { agreeAll.addEventListener('change', function () {
agreementChecks.forEach(check => { agreementChecks.forEach(check => {
if (!check.disabled) {
check.checked = this.checked; check.checked = this.checked;
}
}); });
}); });
} }
@@ -121,10 +148,71 @@
}); });
}); });
// 스크롤 이벤트 감지하여 동의 활성화
document.querySelectorAll('.agreement-scroll').forEach(scrollContainer => {
scrollContainer.addEventListener('scroll', function () {
// 스크롤 끝 감지 (여유 마진 10px)
if (this.scrollTop + this.clientHeight >= this.scrollHeight - 10) {
const contentDiv = this.closest('.agreement-content');
if (contentDiv) {
const targetId = contentDiv.id;
const checkboxId = targetId.replace('Content', '');
const checkbox = document.getElementById(checkboxId);
if (checkbox && checkbox.disabled) {
checkbox.disabled = false;
// 모든 필수 동의가 활성화되었는지 확인 후 전체동의 해제
if (agreeAll) {
const allEnabled = Array.from(agreementChecks).every(c => !c.disabled);
if (allEnabled) {
agreeAll.disabled = false;
}
}
}
// 동의 버튼 활성화
const agreeBtn = contentDiv.querySelector('.agreement-agree-btn');
if (agreeBtn) {
agreeBtn.disabled = false;
}
}
}
});
});
// 동의 버튼 클릭 시 동작
document.querySelectorAll('.agreement-agree-btn').forEach(btn => {
btn.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
const checkboxId = this.getAttribute('data-checkbox-id');
const checkbox = document.getElementById(checkboxId);
if (checkbox) {
checkbox.checked = true;
checkbox.dispatchEvent(new Event('change'));
}
// 해당 토글 닫기
const contentDiv = this.closest('.agreement-content');
if (contentDiv) {
contentDiv.style.display = 'none';
// 토글 버튼 active 제거
const targetId = contentDiv.id;
const toggleBtn = document.querySelector(`.agreement-toggle-icon[data-target="${targetId}"]`);
if (toggleBtn) {
toggleBtn.classList.remove('active');
}
const parentRow = contentDiv.previousElementSibling;
if (parentRow && parentRow.classList.contains('agreement-item-row')) {
parentRow.classList.remove('active');
}
}
});
});
// 토글 아이콘 이벤트 // 토글 아이콘 이벤트
toggleIcons.forEach(icon => { toggleIcons.forEach(icon => {
icon.addEventListener('click', function (e) { icon.addEventListener('click', function (e) {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
const targetId = this.getAttribute('data-target'); const targetId = this.getAttribute('data-target');
const targetContent = document.getElementById(targetId); const targetContent = document.getElementById(targetId);
const parentRow = this.closest('.agreement-item-row'); const parentRow = this.closest('.agreement-item-row');
@@ -151,6 +239,28 @@
parentRow.classList.add('active'); parentRow.classList.add('active');
} }
// 스크롤 공간이 없거나 매우 짧은 글의 경우 즉시 체크박스 및 동의 버튼 활성화
const scrollContainer = targetContent.querySelector('.agreement-scroll');
if (scrollContainer) {
if (scrollContainer.scrollHeight <= scrollContainer.clientHeight) {
const checkboxId = targetId.replace('Content', '');
const checkbox = document.getElementById(checkboxId);
if (checkbox && checkbox.disabled) {
checkbox.disabled = false;
if (agreeAll) {
const allEnabled = Array.from(agreementChecks).every(c => !c.disabled);
if (allEnabled) {
agreeAll.disabled = false;
}
}
}
const agreeBtn = targetContent.querySelector('.agreement-agree-btn');
if (agreeBtn) {
agreeBtn.disabled = false;
}
}
}
setTimeout(() => { setTimeout(() => {
targetContent.scrollIntoView({ targetContent.scrollIntoView({
behavior: 'smooth', behavior: 'smooth',
@@ -160,7 +270,22 @@
} }
}); });
}); });
// 행 한줄 클릭시 토글 동작 (체크박스 클릭 제외)
document.querySelectorAll('.agreement-item-row').forEach(row => {
row.style.cursor = 'pointer';
row.addEventListener('click', function (e) {
if (e.target.closest('.agreement-checkbox-input') || e.target.closest('.agreement-checkbox-custom')) {
return;
}
const toggleIcon = this.querySelector('.agreement-toggle-icon');
if (toggleIcon) {
toggleIcon.click();
}
}); });
</script> });
});
</script>
</body> </body>
</html> </html>
@@ -20,11 +20,18 @@
<!-- Alert Container --> <!-- Alert Container -->
<div id="alertContainer"></div> <div id="alertContainer"></div>
<!-- Registration Card Wrapper -->
<div class="register-card-wrapper">
<!-- Info Notice --> <!-- Info Notice -->
<div class="org-info-notice" th:if="${!isInvited}"> <div class="org-info-notice" th:if="${!isInvited}">
<ul> <div class="notice-icon-wrapper">
<li>서비스 또는 API 사용을 원하실 경우 법인회원 승인 후 이용하실 수 있습니다.</li> <svg class="notice-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</ul> <circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</div>
<p class="notice-text">서비스 또는 API 사용을 원하실 경우 법인회원 승인 후 이용하실 수 있습니다.</p>
</div> </div>
<!-- Agreement Section --> <!-- Agreement Section -->
@@ -64,6 +71,7 @@
<button type="button" class="btn btn-primary btn_register">가입신청</button> <button type="button" class="btn btn-primary btn_register">가입신청</button>
</div> </div>
</form> </form>
</div>
<!-- Loading Overlay --> <!-- Loading Overlay -->
<div class="org-loading-overlay" id="loadingOverlay"> <div class="org-loading-overlay" id="loadingOverlay">
@@ -206,6 +206,15 @@
</div> </div>
</div> </div>
<!-- 회원 구분별 이용 가능 서비스 (TBD) -->
<section class="signup-roles-tbd">
<h2 class="signup-roles-tbd__title">회원 구분별 이용 가능 서비스</h2>
<div class="signup-roles-tbd__placeholder">
<span class="signup-roles-tbd__badge">TBD</span>
<p class="signup-roles-tbd__desc">회원 구분별 이용 가능 서비스 안내가 준비 중입니다.</p>
</div>
</section>
<div class="signup-action"> <div class="signup-action">
<a th:href="@{/signup}" class="btn-action-primary">회원가입하러 가기</a> <a th:href="@{/signup}" class="btn-action-primary">회원가입하러 가기</a>
</div> </div>
@@ -22,6 +22,9 @@
<span th:if="${statsAggregationMinute != null}" <span th:if="${statsAggregationMinute != null}"
th:text="'통계 데이터는 매시 ' + ${statsAggregationMinute} + '분 집계되며, 집계 이전 시간 기준으로 생성됩니다.'">통계 안내</span> th:text="'통계 데이터는 매시 ' + ${statsAggregationMinute} + '분 집계되며, 집계 이전 시간 기준으로 생성됩니다.'">통계 안내</span>
<span th:if="${statsAggregationMinute == null}">통계 데이터는 매시 집계되며, 집계 이전 시간 기준으로 생성됩니다.</span> <span th:if="${statsAggregationMinute == null}">통계 데이터는 매시 집계되며, 집계 이전 시간 기준으로 생성됩니다.</span>
<span class="statistics-notice-example" th:if="${statsAggregationMinute != null}"
th:text="'예) 매시 ' + ${statsAggregationMinute} + '분 집계 기준 — 현재 12:30이면 12:00 이전, 12:05이면 11:00 이전에 발생한 거래가 대상입니다.'">예시</span>
<span class="statistics-notice-example" th:if="${statsAggregationMinute == null}">예) 현재 12:30이면 12:00 이전, 12:05이면 11:00 이전에 발생한 거래가 대상입니다.</span>
<span th:if="${statsLatestTime != null}" class="statistics-notice-latest" <span th:if="${statsLatestTime != null}" class="statistics-notice-latest"
th:text="'※ 최신 집계 시각: ' + ${statsLatestTime} + ' 기준'">최신 집계 시각</span> th:text="'※ 최신 집계 시각: ' + ${statsLatestTime} + ' 기준'">최신 집계 시각</span>
</div> </div>
@@ -56,7 +56,7 @@
<div class="s2-selection-container"> <div class="s2-selection-container">
<main class="s2-content-area"> <main class="s2-content-area">
<form id="apiSelectorForm" method="post" th:action="@{${formAction}}" class="s2-form" <form id="apiSelectorForm" method="post" th:action="@{${formAction}}" class="s2-form"
th:attr="data-save-action=@{${saveAction}}, data-api-required=${!#strings.contains(formAction, '/myapikey/register')}"> th:attr="data-save-action=@{${saveAction}}, data-api-required=${!#strings.contains(formAction, '/clients/register')}">
<!-- Header filter: Search and Select All --> <!-- Header filter: Search and Select All -->
<div class="s2-filter-header"> <div class="s2-filter-header">
@@ -449,7 +449,7 @@
} }
$('.loading-overlay').show(); $('.loading-overlay').show();
$.ajax({ $.ajax({
url: '/myapikey/api_key_request', url: '/clients/api_key_request',
type: 'POST', type: 'POST',
contentType: 'application/json', contentType: 'application/json',
data: JSON.stringify(requestData) data: JSON.stringify(requestData)
@@ -47,6 +47,7 @@
<li><a th:href="@{/partnership}">피드백/개선요청</a></li> <li><a th:href="@{/partnership}">피드백/개선요청</a></li>
</ul> </ul>
</li> </li>
<li><a href="#" class="nav-link">API Status</a></li>
</ul> </ul>
</nav> </nav>
@@ -82,7 +83,7 @@
<a th:href="@{/users}"><i class="fas fa-users"></i>개발자 관리</a> <a th:href="@{/users}"><i class="fas fa-users"></i>개발자 관리</a>
</li> </li>
<li sec:authorize="hasRole('ROLE_APP')"> <li sec:authorize="hasRole('ROLE_APP')">
<a th:href="@{/myapikey}"><i class="fas fa-key"></i> 관리</a> <a th:href="@{/clients}"><i class="fas fa-key"></i>API 신청 관리</a>
<a th:href="@{/webhook}" sec:authorize="hasRole('ROLE_WEBHOOK')"><i class="fas fa-bell"></i>Webhook 관리</a> <a th:href="@{/webhook}" sec:authorize="hasRole('ROLE_WEBHOOK')"><i class="fas fa-bell"></i>Webhook 관리</a>
<a th:href="@{/statistics/api}"><i class="fas fa-chart-bar"></i>이용 통계</a> <a th:href="@{/statistics/api}"><i class="fas fa-chart-bar"></i>이용 통계</a>
</li> </li>
@@ -228,6 +229,10 @@
<li><a th:href="@{/partnership}">사업 제휴 문의</a></li> <li><a th:href="@{/partnership}">사업 제휴 문의</a></li>
</ul> </ul>
</li> </li>
<!-- API Status -->
<li class="drawer-menu-item">
<a href="#" class="drawer-menu-btn">API Status</a>
</li>
<!-- 마이페이지 (Authenticated Only) --> <!-- 마이페이지 (Authenticated Only) -->
<li class="drawer-menu-item has-submenu" sec:authorize="isAuthenticated()"> <li class="drawer-menu-item has-submenu" sec:authorize="isAuthenticated()">
@@ -239,7 +244,7 @@
</button> </button>
<ul class="drawer-submenu"> <ul class="drawer-submenu">
<li sec:authorize="hasRole('ROLE_CORP_MANAGER')"><a th:href="@{/users}">개발자 관리</a></li> <li sec:authorize="hasRole('ROLE_CORP_MANAGER')"><a th:href="@{/users}">개발자 관리</a></li>
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/myapikey}"> 관리</a></li> <li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/clients}">API 신청 관리</a></li>
<li sec:authorize="hasRole('ROLE_WEBHOOK')"><a th:href="@{/webhook}">Webhook 관리</a></li> <li sec:authorize="hasRole('ROLE_WEBHOOK')"><a th:href="@{/webhook}">Webhook 관리</a></li>
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/statistics/api}">이용 통계</a></li> <li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/statistics/api}">이용 통계</a></li>
<li><a th:href="@{/mypage}">내 정보 관리</a></li> <li><a th:href="@{/mypage}">내 정보 관리</a></li>
@@ -1,16 +1,16 @@
<!DOCTYPE html> <!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"> <html xmlns:th="http://www.thymeleaf.org">
<body> <body>
<!-- Service Left Sidebar Fragment --> <!-- Service Left Sidebar Fragment -->
<aside class="service-sidebar" th:fragment="sidebar(activeMenu)"> <aside class="service-sidebar" th:fragment="sidebar(activeMenu)">
<nav class="service-nav"> <nav class="service-nav">
<!-- 서비스 소개 그룹 (Service) --> <!-- 서비스 소개 그룹 (Service) -->
<th:block th:if="${activeMenu == 'intro' or activeMenu == 'guide' or activeMenu == 'oauth2' or activeMenu == 'webhookGuide'}"> <th:block
<a th:href="@{/service/intro}" th:if="${activeMenu == 'intro' or activeMenu == 'guide' or activeMenu == 'oauth2' or activeMenu == 'webhookGuide'}">
th:classappend="${activeMenu == 'intro'} ? 'service-nav__item--active' : ''" <a th:href="@{/service/intro}" th:classappend="${activeMenu == 'intro'} ? 'service-nav__item--active' : ''"
class="service-nav__item">API 포탈 소개</a> class="service-nav__item">API 포탈 소개</a>
<a th:href="@{/service/guide}" <a th:href="@{/service/guide}" th:classappend="${activeMenu == 'guide'} ? 'service-nav__item--active' : ''"
th:classappend="${activeMenu == 'guide'} ? 'service-nav__item--active' : ''"
class="service-nav__item">회원가입 안내</a> class="service-nav__item">회원가입 안내</a>
<a th:href="@{/service/oauth2-guide}" <a th:href="@{/service/oauth2-guide}"
th:classappend="${activeMenu == 'oauth2'} ? 'service-nav__item--active' : ''" th:classappend="${activeMenu == 'oauth2'} ? 'service-nav__item--active' : ''"
@@ -21,28 +21,27 @@
</th:block> </th:block>
<!-- 고객지원 그룹 (Customer Support) --> <!-- 고객지원 그룹 (Customer Support) -->
<th:block th:if="${activeMenu == 'notice' or activeMenu == 'faq' or activeMenu == 'qna' or activeMenu == 'feedback'}"> <th:block
<a th:href="@{/portalnotice}" th:if="${activeMenu == 'notice' or activeMenu == 'faq' or activeMenu == 'qna' or activeMenu == 'feedback'}">
th:classappend="${activeMenu == 'notice'} ? 'service-nav__item--active' : ''" <a th:href="@{/portalnotice}" th:classappend="${activeMenu == 'notice'} ? 'service-nav__item--active' : ''"
class="service-nav__item">공지사항</a> class="service-nav__item">공지사항</a>
<a th:href="@{/faq_list}" <a th:href="@{/faq_list}" th:classappend="${activeMenu == 'faq'} ? 'service-nav__item--active' : ''"
th:classappend="${activeMenu == 'faq'} ? 'service-nav__item--active' : ''"
class="service-nav__item">FAQ</a> class="service-nav__item">FAQ</a>
<a th:href="@{/inquiry}" <a th:href="@{/inquiry}" th:classappend="${activeMenu == 'qna'} ? 'service-nav__item--active' : ''"
th:classappend="${activeMenu == 'qna'} ? 'service-nav__item--active' : ''"
class="service-nav__item">Q&A</a> class="service-nav__item">Q&A</a>
<a th:href="@{/partnership}" <a th:href="@{/partnership}" th:classappend="${activeMenu == 'feedback'} ? 'service-nav__item--active' : ''"
th:classappend="${activeMenu == 'feedback'} ? 'service-nav__item--active' : ''"
class="service-nav__item">피드백/개선요청</a> class="service-nav__item">피드백/개선요청</a>
</th:block> </th:block>
<!-- 마이페이지 그룹 (My Page) --> <!-- 마이페이지 그룹 (My Page) -->
<th:block th:if="${activeMenu == 'users' or activeMenu == 'apiKey' or activeMenu == 'statistics' or activeMenu == 'webhook' or activeMenu == 'profile' or activeMenu == 'password'}"> <th:block
th:if="${activeMenu == 'users' or activeMenu == 'apiKey' or activeMenu == 'statistics' or activeMenu == 'webhook' or activeMenu == 'profile' or activeMenu == 'password'}">
<!-- Profile Section --> <!-- Profile Section -->
<div class="service-sidebar__profile"> <div class="service-sidebar__profile">
<div class="avatar"> <div class="avatar">
<svg viewBox="0 0 24 24" fill="currentColor"> <svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/> <path
d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
</svg> </svg>
</div> </div>
<p class="user-name"><span th:text="${#authentication.principal.userName}">사용자</span></p> <p class="user-name"><span th:text="${#authentication.principal.userName}">사용자</span></p>
@@ -50,27 +49,20 @@
<div class="service-sidebar__divider"></div> <div class="service-sidebar__divider"></div>
<a th:href="@{/users}" <a th:href="@{/users}" th:classappend="${activeMenu == 'users'} ? 'service-nav__item--active' : ''"
th:classappend="${activeMenu == 'users'} ? 'service-nav__item--active' : ''" class="service-nav__item" sec:authorize="hasRole('ROLE_CORP_MANAGER')">개발자 관리</a>
class="service-nav__item"
sec:authorize="hasRole('ROLE_CORP_MANAGER')">개발자 관리</a>
<a th:href="@{/myapikey}" <a th:href="@{/clients}" th:classappend="${activeMenu == 'apiKey'} ? 'service-nav__item--active' : ''"
th:classappend="${activeMenu == 'apiKey'} ? 'service-nav__item--active' : ''" class="service-nav__item">API 신청 관리</a>
class="service-nav__item">앱 관리</a>
<a th:href="@{/webhook}" <a th:href="@{/webhook}" th:classappend="${activeMenu == 'webhook'} ? 'service-nav__item--active' : ''"
th:classappend="${activeMenu == 'webhook'} ? 'service-nav__item--active' : ''" class="service-nav__item" sec:authorize="hasRole('ROLE_WEBHOOK')">Webhook 관리</a>
class="service-nav__item"
sec:authorize="hasRole('ROLE_WEBHOOK')">Webhook 관리</a>
<a th:href="@{/statistics/api}" <a th:href="@{/statistics/api}"
th:classappend="${activeMenu == 'statistics'} ? 'service-nav__item--active' : ''" th:classappend="${activeMenu == 'statistics'} ? 'service-nav__item--active' : ''"
class="service-nav__item" class="service-nav__item" sec:authorize="hasRole('ROLE_APP')">이용통계</a>
sec:authorize="hasRole('ROLE_APP')">이용통계</a>
<a th:href="@{/mypage}" <a th:href="@{/mypage}" th:classappend="${activeMenu == 'profile'} ? 'service-nav__item--active' : ''"
th:classappend="${activeMenu == 'profile'} ? 'service-nav__item--active' : ''"
class="service-nav__item">내정보 관리</a> class="service-nav__item">내정보 관리</a>
<a th:href="@{/password/change}" <a th:href="@{/password/change}"
@@ -80,4 +72,5 @@
</nav> </nav>
</aside> </aside>
</body> </body>
</html> </html>
@@ -20,7 +20,7 @@
<!-- Modal Body --> <!-- Modal Body -->
<div class="modal-body"> <div class="modal-body">
<p id="passwordPopupMessage" style="text-align: center; margin-bottom: 24px; color: #64748B;"> <p id="passwordPopupMessage" style="text-align: center; margin-bottom: 24px; color: #64748B; word-break: keep-all;">
계속하려면 비밀번호를 입력해주세요. 계속하려면 비밀번호를 입력해주세요.
</p> </p>

Some files were not shown because too many files have changed in this diff Show More