Merge branch 'master' into feats/api-status
This commit is contained in:
@@ -64,6 +64,12 @@ public class ApiController {
|
||||
|
||||
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("totalApiCount", searchResult.get("totalApiCount"));
|
||||
model.addAttribute("services", searchResult.get("services"));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 java.io.BufferedReader;
|
||||
import java.io.DataOutputStream;
|
||||
@@ -37,13 +38,18 @@ public class APISender {
|
||||
|
||||
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);
|
||||
|
||||
String response = getResponse(connection);
|
||||
|
||||
connection.disconnect();
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug(response);
|
||||
logger.debug("APISender POST(json) 응답 - uri={}, response={}", uri, response);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
@@ -85,11 +91,15 @@ public class APISender {
|
||||
}
|
||||
connection.setDoOutput(true);
|
||||
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug("APISender GET 요청 - uri={}", appendUriAndParams(uri, params));
|
||||
}
|
||||
|
||||
String response = getResponse(connection);
|
||||
connection.disconnect();
|
||||
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug(response);
|
||||
logger.debug("APISender GET 응답 - uri={}, response={}", uri, response);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
@@ -110,6 +120,12 @@ public class APISender {
|
||||
}
|
||||
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())) {
|
||||
byte[] requestBodyBytes = requestBody.getBytes(StandardCharsets.UTF_8);
|
||||
outputStream.write(requestBodyBytes);
|
||||
@@ -120,7 +136,7 @@ public class APISender {
|
||||
connection.disconnect();
|
||||
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug(response);
|
||||
logger.debug("APISender POST 응답 - uri={}, response={}", uri, 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.service.ApiService;
|
||||
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.enums.DjbGatewayMode;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.URLEncoder;
|
||||
import java.util.Enumeration;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
@@ -72,6 +73,8 @@ public class ApiTesterFilter implements Filter {
|
||||
String auditId = ApiTesterAuditLogger.newAuditId();
|
||||
long auditStart = System.currentTimeMillis();
|
||||
String auditType = "-";
|
||||
// 실제 프록시 호출 대상 URL (mock 은 mockUrl, 토큰 GW 는 base-url+token-path 로 original-url 과 다를 수 있음) — 오류 로그용
|
||||
String proxyTarget = null;
|
||||
|
||||
// original-url 헤더가 없으면 프록시 대상을 알 수 없음 → 400 (NPE 방지)
|
||||
if (url == null || url.trim().isEmpty()) {
|
||||
@@ -86,22 +89,24 @@ public class ApiTesterFilter implements Filter {
|
||||
// 반환하기 위해 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);
|
||||
DjbGatewayMode gatewayMode = gatewayProperty.resolveGatewayMode();
|
||||
boolean tokenRequest = url.contains(DjbTestbedGatewayProperty.PORTAL_MOCK_TOKEN_PATH)
|
||||
|| url.contains(gatewayProperty.tokenPath());
|
||||
boolean mockTokenRequest = url.contains(DjbTestbedGatewayProperty.PORTAL_MOCK_TOKEN_PATH);
|
||||
boolean tokenRequest = mockTokenRequest || url.contains(gatewayProperty.tokenPath());
|
||||
|
||||
// 본문은 한 번만 읽어 프록시 forward 와 감사 로그에 함께 사용 (GET 이면 빈 문자열)
|
||||
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) {
|
||||
String body = requestBody;
|
||||
|
||||
if (gatewayMode == DjbGatewayMode.PORTAL_MOCK) {
|
||||
if (mockTokenRequest) {
|
||||
auditType = "TOKEN_MOCK";
|
||||
// PortalMock: 고정 mock 토큰 반환 (기존 동작 유지)
|
||||
// mock 응답유형 API: 고정 mock 토큰 즉시 발급 (Secret 검증 없음)
|
||||
Map<String, String> params = new HashMap<>();
|
||||
String[] pairs = body.split("&");
|
||||
for (String pair : pairs) {
|
||||
@@ -130,6 +135,12 @@ public class ApiTesterFilter implements Filter {
|
||||
headers.put("Content-Type", "application/x-www-form-urlencoded");
|
||||
headers.put("Accept", "application/json");
|
||||
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);
|
||||
|
||||
response.setContentType("application/json");
|
||||
@@ -191,6 +202,15 @@ public class ApiTesterFilter implements Filter {
|
||||
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);
|
||||
String responseStr;
|
||||
if ("post".equalsIgnoreCase(apiSpecInfoDto.getApiMethod())) {
|
||||
@@ -198,23 +218,34 @@ public class ApiTesterFilter implements Filter {
|
||||
} else {
|
||||
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.getWriter().println(responseStr);
|
||||
}
|
||||
|
||||
} catch (java.net.SocketTimeoutException e) {
|
||||
// 연결/응답 타임아웃 (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,
|
||||
"{\"error\":\"게이트웨이 응답 시간 초과(timeout)\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
|
||||
} catch (IOException e) {
|
||||
// 연결 실패 등 네트워크 오류
|
||||
logger.error("테스트베드 프록시 호출 실패", e);
|
||||
// 연결 실패 등 네트워크 오류 (ConnectException: 대상 다운/포트 닫힘, UnknownHostException: 주소 오기입 등)
|
||||
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,
|
||||
"{\"error\":\"게이트웨이 호출 실패\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
|
||||
"{\"error\":\"게이트웨이 호출 실패\",\"detail\":\"" + escapeJson(e.getClass().getSimpleName() + ": " + e.getMessage()) + "\"}");
|
||||
} catch (Exception e) {
|
||||
// 그 외 예기치 못한 오류도 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,
|
||||
"{\"error\":\"요청 처리 중 오류\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
|
||||
} 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 {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
BufferedReader reader = request.getReader();
|
||||
@@ -231,6 +269,39 @@ public class ApiTesterFilter implements Filter {
|
||||
while ((line = reader.readLine()) != null) {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -250,6 +321,27 @@ public class ApiTesterFilter implements Filter {
|
||||
}
|
||||
|
||||
/** 상태코드 + 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 {
|
||||
((HttpServletResponse) response).setStatus(status);
|
||||
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.service.AdminGatewayClient;
|
||||
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.util.ApiServiceHelper;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
@@ -49,7 +52,7 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
@Slf4j
|
||||
@Controller
|
||||
@RequestMapping("/myapikey")
|
||||
@RequestMapping("/clients")
|
||||
@RequiredArgsConstructor
|
||||
@SessionAttributes({"apiKeyRegistration", "apiKeyModification"})
|
||||
public class MyAppController {
|
||||
@@ -82,6 +85,8 @@ public class MyAppController {
|
||||
private final ApiServiceHelper apiServiceHelper;
|
||||
private final FileTypeDetector fileTypeDetector;
|
||||
private final AdminGatewayClient adminGatewayClient;
|
||||
private final TwoFactorService twoFactorService;
|
||||
private final TwoFactorProperties twoFactorProperties;
|
||||
|
||||
private static final long MAX_APP_ICON_BYTES = 2L * 1024 * 1024; // 2MB
|
||||
|
||||
@@ -113,14 +118,14 @@ public class MyAppController {
|
||||
@Secured("ROLE_APP")
|
||||
public ModelAndView appRequestDetail(@RequestParam(value = "id", required = false) String id, Model model) {
|
||||
if (id == null) {
|
||||
return new ModelAndView("redirect:/myapikey");
|
||||
return new ModelAndView("redirect:/clients");
|
||||
}
|
||||
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
AppRequestDTO appRequest = appServiceFacade.getAppRequestById(id, user.getPortalOrg());
|
||||
|
||||
if (appRequest == null) {
|
||||
return new ModelAndView("redirect:/myapikey");
|
||||
return new ModelAndView("redirect:/clients");
|
||||
}
|
||||
|
||||
// API 목록 조회 및 설정
|
||||
@@ -151,14 +156,14 @@ public class MyAppController {
|
||||
@Secured("ROLE_APP")
|
||||
public ModelAndView credentialDetail(@RequestParam(value = "id", required = false) String id, Model model) {
|
||||
if (id == null) {
|
||||
return new ModelAndView("redirect:/myapikey");
|
||||
return new ModelAndView("redirect:/clients");
|
||||
}
|
||||
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
ClientDTO apiKey = appServiceFacade.getApiKey(user.getPortalOrg().getId(), id);
|
||||
|
||||
if (apiKey == null) {
|
||||
return new ModelAndView("redirect:/myapikey");
|
||||
return new ModelAndView("redirect:/clients");
|
||||
}
|
||||
|
||||
// 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회만 제공됩니다.
|
||||
*
|
||||
* @param requestData clientId, password 포함
|
||||
* @param requestData clientId 포함
|
||||
* @return {success, secret} / {success:false, alreadyRevealed:true} / {success:false, message}
|
||||
*/
|
||||
@PostMapping("/credential/reveal-secret")
|
||||
@@ -331,7 +338,6 @@ public class MyAppController {
|
||||
Map<String, Object> result = new java.util.HashMap<>();
|
||||
|
||||
String clientId = requestData.get("clientId");
|
||||
String password = requestData.get("password");
|
||||
|
||||
if (clientId == null || clientId.trim().isEmpty()) {
|
||||
result.put("success", false);
|
||||
@@ -341,14 +347,7 @@ public class MyAppController {
|
||||
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
|
||||
// 1. 본인 확인 (비밀번호)
|
||||
if (!appServiceFacade.verifyUserPassword(user, password)) {
|
||||
result.put("success", false);
|
||||
result.put("message", "비밀번호가 일치하지 않습니다.");
|
||||
return result;
|
||||
}
|
||||
|
||||
// 2. 소유권 확인 + 1회 노출 + 물리 삭제
|
||||
// 소유권 확인 + 1회 노출 + 물리 삭제 (본인 확인은 step-up 2FA 인터셉터가 선행)
|
||||
try {
|
||||
String secret = appServiceFacade.revealAndDeleteClientSecret(user.getPortalOrg().getId(), clientId);
|
||||
if (secret == null) {
|
||||
@@ -430,7 +429,7 @@ public class MyAppController {
|
||||
@Secured("ROLE_API_KEY_REQUEST_VIEW")
|
||||
public ModelAndView apiRequestDetail(@RequestParam(value = "id", required = false) String id, Model model) {
|
||||
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();
|
||||
@@ -528,7 +527,7 @@ public class MyAppController {
|
||||
registration.setIpWhitelistFromString(ipWhitelist);
|
||||
}
|
||||
|
||||
return new ModelAndView("redirect:/myapikey/register/step2");
|
||||
return new ModelAndView("redirect:/clients/register/step2");
|
||||
|
||||
} catch (Exception e) {
|
||||
setupStepModel(model, 1);
|
||||
@@ -593,7 +592,7 @@ public class MyAppController {
|
||||
// 1단계가 완료되었는지 검증
|
||||
if (!registration.isStep1Complete()) {
|
||||
redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요.");
|
||||
return new ModelAndView("redirect:/myapikey/register/step1");
|
||||
return new ModelAndView("redirect:/clients/register/step1");
|
||||
}
|
||||
|
||||
// 서비스 카테고리만 가져오기 (API는 AJAX로 로드됨)
|
||||
@@ -624,7 +623,7 @@ public class MyAppController {
|
||||
}
|
||||
|
||||
// 1단계로 리다이렉트
|
||||
return new ModelAndView("redirect:/myapikey/register/step1");
|
||||
return new ModelAndView("redirect:/clients/register/step1");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -642,7 +641,7 @@ public class MyAppController {
|
||||
// 1단계가 완료되었는지 검증
|
||||
if (!registration.isStep1Complete()) {
|
||||
redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요.");
|
||||
return new ModelAndView("redirect:/myapikey/register/step1");
|
||||
return new ModelAndView("redirect:/clients/register/step1");
|
||||
}
|
||||
|
||||
// API 선택은 선택 사항 — 미선택(빈 목록)도 허용한다.
|
||||
@@ -651,7 +650,7 @@ public class MyAppController {
|
||||
// 등록이 완료되었는지 최종 검증
|
||||
if (!registration.isComplete()) {
|
||||
redirectAttributes.addFlashAttribute("error", "등록 정보가 완전하지 않습니다.");
|
||||
return new ModelAndView("redirect:/myapikey/register/step1");
|
||||
return new ModelAndView("redirect:/clients/register/step1");
|
||||
}
|
||||
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
@@ -668,12 +667,12 @@ public class MyAppController {
|
||||
// 성공적으로 완료된 후 세션 초기화
|
||||
sessionStatus.setComplete();
|
||||
|
||||
return new ModelAndView("redirect:/myapikey/register/step3");
|
||||
return new ModelAndView("redirect:/clients/register/step3");
|
||||
|
||||
} catch (Exception e) {
|
||||
// 실패 시 에러 메시지와 함께 step2로 돌아감
|
||||
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를 거치지 않고 직접 접근한 경우
|
||||
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) {
|
||||
// 등록과 관련된 세션 데이터 초기화
|
||||
sessionStatus.setComplete();
|
||||
return "redirect:/myapikey";
|
||||
return "redirect:/clients";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -754,13 +753,15 @@ public class MyAppController {
|
||||
@Secured("ROLE_API_KEY_REQUEST")
|
||||
public ModelAndView modifyStep1(
|
||||
@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,
|
||||
SessionStatus sessionStatus,
|
||||
Model model,
|
||||
RedirectAttributes redirectAttributes) {
|
||||
|
||||
if (clientId == null) {
|
||||
return new ModelAndView("redirect:/myapikey");
|
||||
return new ModelAndView("redirect:/clients");
|
||||
}
|
||||
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
@@ -768,7 +769,7 @@ public class MyAppController {
|
||||
// 기존 API Key 정보 조회
|
||||
ClientDTO apiKey = appServiceFacade.getApiKey(user.getPortalOrg().getId(), clientId);
|
||||
if (apiKey == null) {
|
||||
return new ModelAndView("redirect:/myapikey");
|
||||
return new ModelAndView("redirect:/clients");
|
||||
}
|
||||
|
||||
// 새로운 수정 세션 시작시에만 초기화
|
||||
@@ -810,6 +811,16 @@ public class MyAppController {
|
||||
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 모델 설정
|
||||
setupStepModel(model, 1);
|
||||
model.addAttribute("userOrg", user.getPortalOrg());
|
||||
@@ -861,7 +872,7 @@ public class MyAppController {
|
||||
modification.setIpWhitelistFromString(ipWhitelist);
|
||||
}
|
||||
|
||||
return new ModelAndView("redirect:/myapikey/modify/step2");
|
||||
return new ModelAndView("redirect:/clients/modify/step2");
|
||||
|
||||
} catch (Exception e) {
|
||||
setupStepModel(model, 1);
|
||||
@@ -883,7 +894,7 @@ public class MyAppController {
|
||||
// 1단계가 완료되었는지 검증
|
||||
if (!modification.isStep1Complete()) {
|
||||
redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요.");
|
||||
return new ModelAndView("redirect:/myapikey/modify/step1?clientId=" + modification.getClientId());
|
||||
return new ModelAndView("redirect:/clients/modify/step1?clientId=" + modification.getClientId());
|
||||
}
|
||||
|
||||
// 서비스 카테고리와 API 목록 가져오기
|
||||
@@ -893,6 +904,8 @@ public class MyAppController {
|
||||
setupStepModel(model, 2);
|
||||
model.addAttribute("apiServices", apiServices);
|
||||
model.addAttribute("modification", modification);
|
||||
// 최종 반영(저장) 직전 2FA 필요 여부 → 폼 JS 분기용
|
||||
model.addAttribute("twofaRequired", isAppModifyTwofaRequired());
|
||||
|
||||
return new ModelAndView(API_KEY_MODIFY_STEP2);
|
||||
}
|
||||
@@ -914,7 +927,7 @@ public class MyAppController {
|
||||
}
|
||||
|
||||
// 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,
|
||||
@ModelAttribute("apiKeyModification") ApiKeyRegistrationDTO modification,
|
||||
SessionStatus sessionStatus,
|
||||
HttpSession session,
|
||||
RedirectAttributes redirectAttributes) {
|
||||
|
||||
// 1단계가 완료되었는지 검증
|
||||
if (!modification.isStep1Complete()) {
|
||||
redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요.");
|
||||
return new ModelAndView("redirect:/myapikey/modify/step1?clientId=" + modification.getClientId());
|
||||
return new ModelAndView("redirect:/clients/modify/step1?clientId=" + modification.getClientId());
|
||||
}
|
||||
|
||||
// API 선택 검증
|
||||
if (selectedApis == null || selectedApis.isEmpty()) {
|
||||
redirectAttributes.addFlashAttribute("error", "최소 1개 이상의 API를 선택해주세요.");
|
||||
return new ModelAndView("redirect:/myapikey/modify/step2");
|
||||
return new ModelAndView("redirect:/clients/modify/step2");
|
||||
}
|
||||
|
||||
// 선택된 API를 세션에 저장
|
||||
@@ -947,7 +961,15 @@ public class MyAppController {
|
||||
// 등록이 완료되었는지 최종 검증
|
||||
if (!modification.isComplete()) {
|
||||
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();
|
||||
@@ -964,12 +986,12 @@ public class MyAppController {
|
||||
// 성공적으로 완료된 후 세션 초기화
|
||||
sessionStatus.setComplete();
|
||||
|
||||
return new ModelAndView("redirect:/myapikey/modify/step3");
|
||||
return new ModelAndView("redirect:/clients/modify/step3");
|
||||
|
||||
} catch (Exception e) {
|
||||
// 실패 시 에러 메시지와 함께 step2로 돌아감
|
||||
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를 거치지 않고 직접 접근한 경우
|
||||
if (modificationComplete == null || !modificationComplete) {
|
||||
return new ModelAndView("redirect:/myapikey");
|
||||
return new ModelAndView("redirect:/clients");
|
||||
}
|
||||
|
||||
// 결과 페이지 표시용 속성 설정
|
||||
@@ -1015,12 +1037,18 @@ public class MyAppController {
|
||||
|
||||
// clientId가 있으면 상세 페이지로, 없으면 목록으로
|
||||
if (clientId != null && !clientId.isEmpty()) {
|
||||
return "redirect:/myapikey/credential_detail?id=" + clientId;
|
||||
return "redirect:/clients/credential_detail?id=" + clientId;
|
||||
} 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,
|
||||
Arrays.asList(new ProcessingState(), new RequestedState()));
|
||||
|
||||
// 승인정보(approval) 없는 신청도 목록에 노출한다. (사용자가 직접 삭제 가능)
|
||||
appRequests.addAll(appRequestRepository.findAllByOrgAndTypeIsInAndApprovalIsNull(portalOrg, types));
|
||||
// 승인정보(approval) 없는 신청도 목록에 노출`한다. (사용자가 직접 삭제 가능)
|
||||
appRequests.addAll(appRequestRepository .findAllByOrgAndTypeIsInAndApprovalIsNull(portalOrg, types));
|
||||
|
||||
return appRequests;
|
||||
}
|
||||
|
||||
+16
-8
@@ -11,7 +11,8 @@ import java.util.Set;
|
||||
* <p>검증 레벨(완화 정책)</p>
|
||||
* <ul>
|
||||
* <li>{@link Level#TWO_FACTOR} — 공통 2FA 팝업(휴대폰/이메일 인증번호). 진입 인터셉터 또는
|
||||
* AJAX 401 신호로 유도. 예: Secret 조회/앱 해지/앱 정보수정.</li>
|
||||
* AJAX 401 신호로 유도. 예: Secret 조회/앱 해지. 앱 정보수정은 최종 반영(commit)
|
||||
* 직전에 컨트롤러가 통과권을 요구한다(다단계 진행 중 중복 인증 방지).</li>
|
||||
* <li>{@link Level#PASSWORD} — 현재 비밀번호 재확인만 요구(2FA 없음). 별도 확인 페이지
|
||||
* ({@code /auth/stepup/password})로 유도. 예: 내 정보 변경({@code /mypage}).</li>
|
||||
* </ul>
|
||||
@@ -40,15 +41,17 @@ public final class StepUpProtectedPaths {
|
||||
}
|
||||
|
||||
/** 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) */
|
||||
public static final String APP_KEY_DELETE = "/myapikey/api_key_delete";
|
||||
/** 앱 정보 수정 페이지 진입 (GET) */
|
||||
public static final String APP_MODIFY_STEP1 = "/myapikey/modify/step1";
|
||||
public static final String APP_KEY_DELETE = "/clients/api_key_delete";
|
||||
/** 앱 정보 수정 최종 반영(commit, POST /modify/step2) — 반영 직전 2FA. 진입/중간 단계는 가드하지 않음 */
|
||||
public static final String APP_MODIFY_COMMIT = "/clients/modify/step2";
|
||||
/** 개인정보 변경 페이지 진입 (GET, 정확 일치) — PASSWORD 레벨 */
|
||||
public static final String MYPAGE = "/mypage";
|
||||
/** 비밀번호 변경 반영(commit, POST) — 반영 직전 2FA. 진입(GET)은 가드하지 않음 */
|
||||
public static final String PASSWORD_CHANGE = "/password/change";
|
||||
/** 회원 탈퇴 반영(commit, POST) — 반영 직전 2FA. 팝업(사유 입력) 후 프론트가 2FA 를 띄운다 */
|
||||
public static final String WITHDRAW = "/withdraw";
|
||||
|
||||
/** PTL_PROPERTY 지점 키 접두 (전체 스위치 two-factor.stepup.enabled 와 구분) */
|
||||
private static final String KEY_PREFIX = "two-factor.stepup.";
|
||||
@@ -63,26 +66,31 @@ public final class StepUpProtectedPaths {
|
||||
static {
|
||||
Map<String, String> keys = new LinkedHashMap<>();
|
||||
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(MYPAGE, KEY_PREFIX + "mypage");
|
||||
keys.put(PASSWORD_CHANGE, KEY_PREFIX + "password-change");
|
||||
keys.put(WITHDRAW, KEY_PREFIX + "withdraw");
|
||||
PATH_TO_KEY = Collections.unmodifiableMap(keys);
|
||||
|
||||
Map<String, Level> levels = new LinkedHashMap<>();
|
||||
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(MYPAGE, Level.PASSWORD);
|
||||
levels.put(PASSWORD_CHANGE, Level.TWO_FACTOR);
|
||||
levels.put(WITHDRAW, Level.TWO_FACTOR);
|
||||
PATH_TO_LEVEL = Collections.unmodifiableMap(levels);
|
||||
|
||||
// 인터셉터 진입 자동 차단: 2FA 레벨 중 "진입 시점" 보호가 필요한 경로만.
|
||||
// - PASSWORD_CHANGE 는 반영(POST commit) 직전에 컨트롤러가 통과권을 요구 → 제외
|
||||
// - APP_MODIFY_COMMIT 도 동일 — 다단계(step1→step2) 진행 중 중복 인증을 막기 위해
|
||||
// 최종 반영 직전에만 컨트롤러가 통과권을 요구 → 제외
|
||||
// - MYPAGE 는 별도 확인 페이지로 컨트롤러가 유도(PASSWORD 레벨) → 제외
|
||||
// - WITHDRAW 는 팝업(사유 입력)→2FA→제출 순서로 프론트가 유도하고
|
||||
// 컨트롤러가 커밋 직전 통과권을 요구 → 제외
|
||||
Set<String> guarded = new java.util.LinkedHashSet<>();
|
||||
guarded.add(REVEAL_SECRET);
|
||||
guarded.add(APP_MODIFY_STEP1);
|
||||
guarded.add(APP_KEY_DELETE);
|
||||
INTERCEPTOR_GUARDED = Collections.unmodifiableSet(guarded);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -20,6 +21,7 @@ public class TwoFactorProperties {
|
||||
public static final String GROUP = "Portal";
|
||||
|
||||
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_ATTEMPT_LIMIT = "two-factor.attempt.limit";
|
||||
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)"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 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 전체 활성화 여부(마스터 스위치) */
|
||||
public boolean isStepUpEnabled() {
|
||||
return parseBool(resolve(KEY_STEPUP_ENABLED, "false", "민감기능 추가 인증(step-up) 전체 활성화 여부 (true/false)"));
|
||||
|
||||
+51
@@ -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;
|
||||
|
||||
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.pagerouter.PageHandler;
|
||||
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 {
|
||||
|
||||
private final TwoFactorService twoFactorService;
|
||||
private final DuplicateLoginService duplicateLoginService;
|
||||
|
||||
public LoginHandler(TwoFactorService twoFactorService) {
|
||||
public LoginHandler(TwoFactorService twoFactorService, DuplicateLoginService duplicateLoginService) {
|
||||
this.twoFactorService = twoFactorService;
|
||||
this.duplicateLoginService = duplicateLoginService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,7 +58,24 @@ public class LoginHandler implements PageHandler {
|
||||
|
||||
// 로그인 2FA 대기 상태면(1차 인증 통과 후) 추가 인증 팝업 자동 오픈 플래그를 내려준다.
|
||||
// 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();
|
||||
|
||||
@@ -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);
|
||||
userSessionService.forceLogoutOtherSessions(normalizedUsername, sessionId);
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package com.eactive.apim.portal.apps.main.controller;
|
||||
|
||||
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.service.IndexStatisticsService;
|
||||
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 javax.servlet.http.HttpSession;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
@@ -17,6 +21,7 @@ public class IndexController {
|
||||
|
||||
private final MainApiFacade mainApiFacade;
|
||||
private final IndexStatisticsService indexStatisticsService;
|
||||
private final AppServiceFacade appServiceFacade;
|
||||
|
||||
/**
|
||||
* 메인 페이지 API는 Portal Property 에 main.service.list 에 등록된 그룹을 기준으로 API를 조회함.
|
||||
@@ -29,7 +34,7 @@ public class IndexController {
|
||||
* @return
|
||||
*/
|
||||
@GetMapping("/")
|
||||
public String index(Model model) {
|
||||
public String index(Model model, HttpSession session) {
|
||||
|
||||
List<ApiServiceDTO> services = mainApiFacade.getOpenApiServices();
|
||||
List<String> hashTags = mainApiFacade.getHashTags();
|
||||
@@ -39,9 +44,37 @@ public class IndexController {
|
||||
|
||||
addAttributesToModel(model, services, hashTags, statistics);
|
||||
|
||||
resolveClientRegisterNudge(model, session);
|
||||
|
||||
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) {
|
||||
|
||||
model.addAttribute("services", apiServices);
|
||||
|
||||
+5
-49
@@ -1,6 +1,5 @@
|
||||
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 lombok.RequiredArgsConstructor;
|
||||
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.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 세션 타이머/유휴 로그아웃/중복로그인 처리용 REST API.
|
||||
* 세션 타이머/유휴 로그아웃 처리용 REST API.
|
||||
*
|
||||
* <p>중복 세션 확인은 비밀번호 검증 전 정보 노출 문제로 로그인 전 사전 체크
|
||||
* ({@code /api/session/check-duplicate})를 제거하고, 1차 인증 통과 후
|
||||
* {@code DuplicateLoginService} 가 처리한다.</p>
|
||||
*
|
||||
* <ul>
|
||||
* <li>GET /api/session/status - 잔여 시간/유효성 폴링 (인증 필요)</li>
|
||||
* <li>POST /api/session/heartbeat - 세션 연장 (lastAccessTime 갱신)</li>
|
||||
* <li>POST /api/session/check-duplicate - 로그인 전 중복 세션 확인 (CSRF 예외)</li>
|
||||
* <li>GET /api/session/ping - 익명 세션 keepalive (로그인/회원가입 페이지)</li>
|
||||
* <li>GET /api/session/csrf - 현재 CSRF 토큰 조회 (로그인 제출 직전 안전망)</li>
|
||||
* </ul>
|
||||
@@ -36,32 +35,8 @@ import java.util.Optional;
|
||||
@RequiredArgsConstructor
|
||||
public class SessionApiController {
|
||||
|
||||
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
|
||||
/** 비밀번호 변경 화면 라이브 체크: 입력 중인 비밀번호에 아이디/휴대전화가 포함되는지 (민감정보는 응답에 미포함) */
|
||||
@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")
|
||||
public ResponseEntity<ValidationResponse> confirmPassword(@RequestParam String inputPassword) {
|
||||
String currentLoginId = SecurityUtil.getCurrentLoginId();
|
||||
@@ -136,7 +143,8 @@ public class AccountController {
|
||||
new SecurityContextLogoutHandler().logout(request, response,
|
||||
SecurityContextHolder.getContext().getAuthentication());
|
||||
|
||||
redirectAttributes.addFlashAttribute("success", "비밀번호가 성공적으로 변경되었습니다.");
|
||||
redirectAttributes.addFlashAttribute("success",
|
||||
"비밀번호가 성공적으로 변경되었습니다.<br>새 비밀번호로 다시 로그인해주세요.");
|
||||
return "redirect:/login";
|
||||
} catch (IllegalArgumentException e) {
|
||||
// 검증 실패(비밀번호 규칙/이력 등) — 사용자에게 안내, 스택은 불필요
|
||||
@@ -182,6 +190,9 @@ public class AccountController {
|
||||
// 기존 user 객체도 유지 (다른 곳에서 필요할 수 있으므로)
|
||||
mav.addObject("user", user);
|
||||
|
||||
// 회원 탈퇴 팝업: 제출 전에 2FA 팝업을 띄울지 여부
|
||||
mav.addObject("withdrawTwofaRequired", isWithdrawTwofaRequired());
|
||||
|
||||
// ROLE_USER인 경우 초대 여부 확인
|
||||
if (currentUser.getRoleCode() == RoleCode.ROLE_USER) {
|
||||
java.util.Optional<UserInvitation> pendingInvitation =
|
||||
@@ -401,21 +412,35 @@ public class AccountController {
|
||||
|
||||
@PostMapping("/withdraw")
|
||||
public String processWithdrawal(
|
||||
@RequestParam(value = "withdrawReason", required = false) String withdrawReason,
|
||||
HttpSession session,
|
||||
RedirectAttributes redirectAttributes) {
|
||||
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();
|
||||
|
||||
// 회원 탈퇴 처리
|
||||
if (currentUser != null) {
|
||||
userFacade.withdrawUser(currentUser.getId());
|
||||
userFacade.withdrawUser(currentUser.getId(), withdrawReason.trim());
|
||||
}
|
||||
|
||||
session.invalidate();
|
||||
SecurityContextHolder.clearContext();
|
||||
|
||||
redirectAttributes.addFlashAttribute("success", "회원 탈퇴 신청이 완료 되었습니다. API Portal 회원 정보가 완전히 삭제 됩니다.");
|
||||
redirectAttributes.addFlashAttribute("success", "회원 탈퇴가 완료 되었습니다. API Portal 회원 정보가 완전히 삭제 되었습니다.");
|
||||
return "redirect:/";
|
||||
} catch (IllegalArgumentException e) {
|
||||
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")
|
||||
public String showVerificationEmailPage(Model model) {
|
||||
try {
|
||||
|
||||
@@ -54,6 +54,7 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
|
||||
private final FileService fileService;
|
||||
private final BasicValidationService validationService;
|
||||
private final UserRegistrationValidationService userRegistrationValidationService;
|
||||
private final com.eactive.apim.portal.apps.user.validator.PasswordValidator passwordValidator;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final AgreementValidator agreementValidator;
|
||||
private final ApprovalService approvalService;
|
||||
@@ -75,14 +76,29 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
|
||||
return new ValidationResponse(false, "입력 정보가 올바르지 않습니다.");
|
||||
}
|
||||
|
||||
// 2025.10.20 - 휴대폰 번호 중복 무시
|
||||
// PortalUser existingUser = portalUserRepository.findByUserNameAndMobileNumber(orgDTO.getUserName(), orgDTO.getMobileNumber());
|
||||
// 개인 가입(@Valid @PasswordRule)과 달리 법인 가입은 컨트롤러 바인딩 검증이 없어
|
||||
// 여기서 서버 측 비밀번호 규칙을 직접 검증한다 (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());
|
||||
|
||||
if(existingUser.isPresent()) {
|
||||
return new ValidationResponse(false, "가입된 계정이 이미 존재합니다.");
|
||||
}
|
||||
|
||||
// 휴대폰 번호 중복 검증 (Portal/user.mobile.duplicate.allow 프로퍼티에 따라 차단)
|
||||
if (portalUserService.isMobileDuplicateCheckEnabled()
|
||||
&& portalUserService.existsByMobileNumber(orgDTO.getMobileNumber())) {
|
||||
return new ValidationResponse(false, "이미 가입된 휴대폰 번호입니다.");
|
||||
}
|
||||
|
||||
try {
|
||||
FileInfo uploadedFile = handleFileUpload(orgDTO.getFiles());
|
||||
if (uploadedFile == null) {
|
||||
|
||||
@@ -10,11 +10,14 @@ public interface UserFacade {
|
||||
|
||||
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 updateCorporateManager(PortalUserDTO portalUserDTO);
|
||||
|
||||
void withdrawUser(String userId);
|
||||
void withdrawUser(String userId, String withdrawalReason);
|
||||
|
||||
void activateUserByEmail(String email);
|
||||
}
|
||||
@@ -60,6 +60,12 @@ public class UserFacadeImpl implements UserFacade {
|
||||
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
|
||||
@Transactional
|
||||
public void updateUser(PortalUserDTO portalUserDTO) {
|
||||
@@ -131,7 +137,7 @@ public class UserFacadeImpl implements UserFacade {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void withdrawUser(String userId) {
|
||||
public void withdrawUser(String userId, String withdrawalReason) {
|
||||
PortalUser user = portalUserService.findById(userId);
|
||||
|
||||
// 법인 관리자 탈퇴 제한
|
||||
@@ -147,7 +153,7 @@ public class UserFacadeImpl implements UserFacade {
|
||||
// 메시지 요청정보 삭제
|
||||
messageRequestFacade.deleteUserMessage(user.getUserName(),user.getLoginId());
|
||||
|
||||
portalUserService.deleteUser(user);
|
||||
portalUserService.deleteUser(user, withdrawalReason);
|
||||
log.info("회원 탈퇴 처리 완료: {}", StringMaskingUtil.maskLoginId(user.getLoginId()));
|
||||
}
|
||||
|
||||
|
||||
@@ -187,7 +187,8 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
||||
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) {
|
||||
return new ValidationResponse(false, "가입된 계정이 이미 존재합니다.");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.eactive.apim.portal.apps.user.service;
|
||||
|
||||
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.UserPasswordHistory;
|
||||
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) {
|
||||
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.mapper.PortalUserMapper;
|
||||
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.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.EncryptionUtil;
|
||||
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.portaluser.entity.PortalUser;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||
@@ -136,12 +136,9 @@ public class PortalUserAuthService implements UserDetailsService {
|
||||
throw new UserNotFoundException("입력하신 사용자 정보가 올바르지 않습니다. 다시 확인해 주세요.");
|
||||
}
|
||||
|
||||
return users.stream().map(user -> {
|
||||
PortalUserDTO dto = portalUserMapper.toDTO(user);
|
||||
dto.setMaskedEmailAddr(StringMaskingUtil.maskEmail(dto.getLoginId()));
|
||||
dto.setLoginId(null);
|
||||
return dto;
|
||||
}).collect(Collectors.toList());
|
||||
// 아이디 찾기: 이름+휴대폰 본인인증을 마친 사용자에게 보여주는 결과이므로
|
||||
// 이메일(아이디)을 마스킹 없이 그대로 반환한다.
|
||||
return users.stream().map(portalUserMapper::toDTO).collect(Collectors.toList());
|
||||
|
||||
} catch (UserNotFoundException e) {
|
||||
throw e;
|
||||
@@ -155,6 +152,8 @@ public class PortalUserAuthService implements UserDetailsService {
|
||||
}
|
||||
|
||||
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}$")) {
|
||||
throw new UserNotFoundException("유효하지 않은 휴대폰 번호 형식입니다.");
|
||||
}
|
||||
@@ -164,6 +163,9 @@ public class PortalUserAuthService implements UserDetailsService {
|
||||
|
||||
String tempPassword = EncryptionUtil.generateNewPassword();
|
||||
portalUser.setPasswordHash(passwordEncoder.encode(tempPassword));
|
||||
// 임시 비밀번호 발급 → 변경일을 null 로 초기화해 로그인 시 강제 비밀번호 변경을 유도한다
|
||||
// (LoginFinalizer.applyPostLoginState 의 passwordChangeDate == null 분기)
|
||||
portalUser.setPasswordChangeDate(null);
|
||||
if ("Y".equalsIgnoreCase(portalUser.getAccountLockYn())) {
|
||||
portalUser.setAccountLockYn("N");
|
||||
}
|
||||
@@ -186,7 +188,7 @@ public class PortalUserAuthService implements UserDetailsService {
|
||||
@Transactional
|
||||
public void reactivateDormantAccount(String loginId, String password, String mobileNumber) {
|
||||
try {
|
||||
PortalUser portalUser = portalUserRepository.findByLoginIdAndMobileNumber(loginId, mobileNumber)
|
||||
PortalUser portalUser = portalUserRepository.findByLoginIdAndMobileNumber(loginId, PhoneNumberUtil.normalize(mobileNumber))
|
||||
.orElseThrow(() -> new UserNotFoundException("입력하신 사용자 정보가 올바르지 않습니다. 다시 확인해 주세요."));
|
||||
|
||||
if (!passwordEncoder.matches(password, portalUser.getPasswordHash())) {
|
||||
|
||||
@@ -280,7 +280,7 @@ public class PortalUserService {
|
||||
return portalUserRepository.save(user);
|
||||
}
|
||||
|
||||
public void deleteUser(PortalUser user) {
|
||||
public void deleteUser(PortalUser user, String withdrawalReason) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
String withdrawalDate = now.format(DateTimeFormatter.ofPattern("yyyyMMddHHmm"));
|
||||
|
||||
@@ -290,6 +290,12 @@ public class PortalUserService {
|
||||
user.setMobileNumber("");
|
||||
user.setPasswordHash("");
|
||||
|
||||
// 탈퇴 사유 보존 (컬럼 길이 200 초과분은 잘라 저장)
|
||||
if (withdrawalReason != null && withdrawalReason.length() > 200) {
|
||||
withdrawalReason = withdrawalReason.substring(0, 200);
|
||||
}
|
||||
user.setWithdrawalReason(withdrawalReason);
|
||||
|
||||
user.setUserStatus(PortalUserEnums.UserStatus.REMOVED);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 폼(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) {
|
||||
return maskName(name, null, null);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.eactive.apim.portal.common.validator;
|
||||
|
||||
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||
import org.apache.commons.beanutils.PropertyUtils;
|
||||
|
||||
import javax.validation.ConstraintValidator;
|
||||
@@ -72,24 +73,12 @@ public class PasswordRuleValidator implements ConstraintValidator<PasswordRule,
|
||||
return false;
|
||||
}
|
||||
|
||||
if (loginId != null && !loginId.isEmpty()) {
|
||||
String[] loginParts = loginId.split("@");
|
||||
if (loginParts.length > 0) {
|
||||
String username = loginParts[0].toUpperCase();
|
||||
if (tmpPw.contains(username)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (containsLoginIdLocalPart(password, loginId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Mobile number validation
|
||||
if (mobileNumber != null && !mobileNumber.isEmpty()) {
|
||||
String[] mobileParts = mobileNumber.split("-");
|
||||
for (String part : mobileParts) {
|
||||
if (!part.isEmpty() && tmpPw.contains(part)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (containsMobileSegment(password, mobileNumber)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 공백 체크
|
||||
@@ -129,6 +118,33 @@ public class PasswordRuleValidator implements ConstraintValidator<PasswordRule,
|
||||
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) {
|
||||
// 첫 글자 A-Z / 0-9
|
||||
return (first > 47 && third < 58) || (first > 64 && third < 91);
|
||||
|
||||
+8
-3
@@ -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.user.service.PortalUserLogService;
|
||||
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.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.common.util.StringRepeatUtil;
|
||||
@@ -47,14 +48,17 @@ public class PortalAuthenticationFailureHandler implements AuthenticationFailure
|
||||
private final PortalUserRepository portalUserRepository;
|
||||
private final PortalUserLogService userLogService;
|
||||
private final MessageHandlerService messageHandlerService;
|
||||
private final LoginLockPolicy loginLockPolicy;
|
||||
|
||||
|
||||
public PortalAuthenticationFailureHandler(PortalUserRepository portalUserRepository,
|
||||
PortalUserLogService userLogService,
|
||||
MessageHandlerService messageHandlerService) {
|
||||
MessageHandlerService messageHandlerService,
|
||||
LoginLockPolicy loginLockPolicy) {
|
||||
this.portalUserRepository = portalUserRepository;
|
||||
this.userLogService = userLogService;
|
||||
this.messageHandlerService = messageHandlerService;
|
||||
this.loginLockPolicy = loginLockPolicy;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -73,15 +77,16 @@ public class PortalAuthenticationFailureHandler implements AuthenticationFailure
|
||||
PortalUser user = portalUserRepository.findPortalUserByEmailAddr(normalizedUsername)
|
||||
.orElseThrow(() -> new UserNotFoundException(normalizedUsername));
|
||||
|
||||
int lockCount = loginLockPolicy.lockCount();
|
||||
user.setLoginFailureCount(user.getLoginFailureCount() + 1);
|
||||
if (user.getLoginFailureCount() >= 5) {
|
||||
if (user.getLoginFailureCount() >= lockCount) {
|
||||
user.setAccountLockYn("Y");
|
||||
|
||||
// 계정 잠금 알림
|
||||
messageHandlerService.publishEvent(
|
||||
MessageCode.USER_ACCOUNT_LOCKED,
|
||||
MessageRecipient.of(user),
|
||||
Maps.of("reason", "5회 이상 로그인 실패로 인한 계정 잠금")) ;;
|
||||
Maps.of("reason", lockCount + "회 이상 로그인 실패로 인한 계정 잠금"));
|
||||
}
|
||||
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.common.security.LoginLockPolicy;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||
@@ -28,6 +29,7 @@ public class PortalAuthenticationManager implements AuthenticationManager {
|
||||
private final PortalUserAuthService portalUserAuthService;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final MessageHandlerService messageHandlerService;
|
||||
private final LoginLockPolicy loginLockPolicy;
|
||||
|
||||
@Override
|
||||
@Transactional(noRollbackFor = {AuthenticationException.class})
|
||||
@@ -40,7 +42,7 @@ public class PortalAuthenticationManager implements AuthenticationManager {
|
||||
|
||||
|
||||
if (!user.isAccountNonLocked()) {
|
||||
if (user.getLoginFailureCount() >= 5) {
|
||||
if (user.getLoginFailureCount() >= loginLockPolicy.lockCount()) {
|
||||
throw new LockedException("계정이 잠겼습니다. 비밀번호 초기화 또는 관리자에게 문의하세요.");
|
||||
}
|
||||
|
||||
|
||||
+25
-2
@@ -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.TwoFactorService;
|
||||
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.portaluser.entity.PortalUser;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||
@@ -29,7 +30,9 @@ import java.io.IOException;
|
||||
* 비워 사용자를 익명으로 되돌린 뒤 {@code /login?twofactor=1} 로 보낸다. 로그인 페이지가
|
||||
* 공통 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>
|
||||
*/
|
||||
@Service
|
||||
@@ -41,6 +44,7 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
|
||||
private final LoginFinalizer loginFinalizer;
|
||||
private final TwoFactorService twoFactorService;
|
||||
private final TwoFactorProperties twoFactorProperties;
|
||||
private final DuplicateLoginService duplicateLoginService;
|
||||
|
||||
@Override
|
||||
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
|
||||
@@ -56,7 +60,9 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
|
||||
boolean dormant = PortalUserEnums.UserStatus.DORMANT.equals(user.getUserStatus());
|
||||
|
||||
// 로그인 2FA: ID/PW 는 맞았으므로 실패카운트만 리셋하고, 최종 확정은 2FA 성공까지 보류한다.
|
||||
if (twoFactorProperties.isLoginEnabled() && !dormant) {
|
||||
// 대상 역할(two-factor.login.target-roles, 기본 법인관리자만)에 한해 적용한다.
|
||||
if (twoFactorProperties.isLoginEnabled() && !dormant
|
||||
&& twoFactorProperties.isLoginTargetRole(user.getRoleCode())) {
|
||||
user.setLoginFailureCount(0);
|
||||
portalUserRepository.save(user);
|
||||
|
||||
@@ -71,6 +77,23 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
|
||||
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) → 기존과 동일하게 즉시 확정
|
||||
String redirect = loginFinalizer.finalizeLogin(user, username, request, LoginType.NORMAL);
|
||||
response.sendRedirect(redirect);
|
||||
|
||||
@@ -108,7 +108,6 @@ public class PortalConfigSecurity {
|
||||
.csrf(csrf -> csrf
|
||||
.csrfTokenRepository(csrfTokenRepository)
|
||||
.ignoringRequestMatchers(new AntPathRequestMatcher("/_proxy/**/*"))
|
||||
.ignoringRequestMatchers(new AntPathRequestMatcher("/api/session/check-duplicate"))
|
||||
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/migration/**"))
|
||||
)
|
||||
// 로그인 페이지에 오래 머물러 세션(=CSRF 토큰 저장소)이 타임아웃되면
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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.password.PasswordEncoder;
|
||||
|
||||
@@ -17,8 +16,5 @@ public class DjbPasswordEncoder implements PasswordEncoder {
|
||||
@Override
|
||||
public boolean matches(CharSequence rawPassword, String encodedPassword) {
|
||||
return bcryptEncoder.matches(rawPassword, encodedPassword);
|
||||
// DjbSafedbWrapper safedb = DjbSafedbWrapper.getInstance();
|
||||
// String bcryptHash = safedb.decryptNotRnno(encodedPassword);
|
||||
// return bcryptEncoder.matches(rawPassword, bcryptHash);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,38 +366,38 @@ page:
|
||||
name: "개발자 정보"
|
||||
path: "/users/detail"
|
||||
apikey:
|
||||
name: "앱 관리"
|
||||
path: "/myapikey"
|
||||
name: "API 신청 관리"
|
||||
path: "/clients"
|
||||
app_request_detail:
|
||||
name: "인증키 신청 상세"
|
||||
path: "/myapikey/app_request_detail"
|
||||
path: "/clients/app_request_detail"
|
||||
credential_detail:
|
||||
name: "인증키 정보"
|
||||
path: "/myapikey/credential_detail"
|
||||
path: "/clients/credential_detail"
|
||||
password_verify:
|
||||
name: "비밀번호 변경"
|
||||
path: "/password/verify"
|
||||
password_change:
|
||||
name: "비밀번호 변경"
|
||||
path: "/password/change"
|
||||
myapikey_register_step1:
|
||||
clients_register_step1:
|
||||
name: "앱 생성 (기본 정보)"
|
||||
path: "/myapikey/register/step1"
|
||||
myapikey_register_step2:
|
||||
path: "/clients/register/step1"
|
||||
clients_register_step2:
|
||||
name: "앱 생성 (API 선택)"
|
||||
path: "/myapikey/register/step2"
|
||||
myapikey_register_step3:
|
||||
path: "/clients/register/step2"
|
||||
clients_register_step3:
|
||||
name: "앱 생성 요청 완료"
|
||||
path: "/myapikey/register/step3"
|
||||
myapikey_modify_step1:
|
||||
path: "/clients/register/step3"
|
||||
clients_modify_step1:
|
||||
name: "앱 수정 (기본 정보)"
|
||||
path: "/myapikey/modify/step1"
|
||||
myapikey_modify_step2:
|
||||
path: "/clients/modify/step1"
|
||||
clients_modify_step2:
|
||||
name: "앱 수정 (API 선택)"
|
||||
path: "/myapikey/modify/step2"
|
||||
myapikey_modify_step3:
|
||||
path: "/clients/modify/step2"
|
||||
clients_modify_step3:
|
||||
name: "앱 수정 요청 완료"
|
||||
path: "/myapikey/modify/step3"
|
||||
path: "/clients/modify/step3"
|
||||
api_statistics:
|
||||
name: "이용 통계"
|
||||
path: "/statistics/api"
|
||||
|
||||
+4677
-649
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -449,7 +449,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
// Form submit
|
||||
// - Webhook/앱 변경은 API 가 필수(form data-api-required=true)이므로 미선택 시 차단한다.
|
||||
// - 앱 신청(myapikey register)은 선택 사항이라, 미선택 시 확인 팝업으로 한 번 더 묻고 진행한다.
|
||||
// - 앱 신청(clients register)은 선택 사항이라, 미선택 시 확인 팝업으로 한 번 더 묻고 진행한다.
|
||||
form.addEventListener('submit', function(e) {
|
||||
if (selectedApis.size !== 0) {
|
||||
syncHiddenSelected();
|
||||
|
||||
@@ -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,
|
||||
pendingInvitationToken: '',
|
||||
pendingInvitationOrgName: '',
|
||||
needClientRegister: false,
|
||||
sessionSuccessMsg: '',
|
||||
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';
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
* 비밀번호 문자열 정책 라이브 검증 (공용)
|
||||
*
|
||||
* 서버 검증기 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">
|
||||
@@ -31,7 +33,7 @@
|
||||
return false;
|
||||
}
|
||||
|
||||
// 규칙별 판정 함수 (통과=true)
|
||||
// 규칙별 판정 함수 (통과=true). ctx = { loginId, mobile } — 값이 없으면 해당 규칙은 통과 처리
|
||||
var RULES = {
|
||||
length: function (pw) { return pw.length >= 8 && pw.length <= 50; },
|
||||
letter: function (pw) { return /[a-zA-Z]/.test(pw); },
|
||||
@@ -39,23 +41,46 @@
|
||||
special: function (pw) { return /[^A-Za-z0-9_]/.test(pw); }, // 서버 정규식 \W 기준 (밑줄 제외)
|
||||
nospace: function (pw) { return !/\s/.test(pw); },
|
||||
norepeat: function (pw) { return !/(\w)\1\1/.test(pw.toUpperCase()); },
|
||||
noseq: function (pw) { return !hasSequential(pw); }
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
// 전체 문자열 규칙 통과 여부
|
||||
function isValid(pw) {
|
||||
// 전체 문자열 규칙 통과 여부 (ctx 미전달 시 noid/nomobile 은 통과 — 서버 검증에 위임)
|
||||
function isValid(pw, ctx) {
|
||||
if (!pw) {
|
||||
return false;
|
||||
}
|
||||
for (var key in RULES) {
|
||||
if (RULES.hasOwnProperty(key) && !RULES[key](pw)) {
|
||||
if (RULES.hasOwnProperty(key) && !RULES[key](pw, ctx || {})) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
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) {
|
||||
var $input = (input && input.jquery) ? input : $(input);
|
||||
var $list = (list && list.jquery) ? list : $(list);
|
||||
@@ -64,8 +89,18 @@
|
||||
return;
|
||||
}
|
||||
|
||||
function ctxValue(attr) {
|
||||
var id = $list.attr(attr);
|
||||
var el = id ? document.getElementById(id) : null;
|
||||
return el ? el.value : '';
|
||||
}
|
||||
|
||||
function update() {
|
||||
var pw = $input.val() || '';
|
||||
var ctx = {
|
||||
loginId: ctxValue('data-context-loginid'),
|
||||
mobile: ctxValue('data-context-mobile')
|
||||
};
|
||||
$items.each(function () {
|
||||
var $li = $(this);
|
||||
var rule = RULES[$li.attr('data-rule')];
|
||||
@@ -76,15 +111,29 @@
|
||||
if (pw.length === 0) {
|
||||
$li.addClass('is-idle');
|
||||
} 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);
|
||||
update();
|
||||
}
|
||||
|
||||
// hidden input 등 이벤트 없이 값이 세팅되는 컨텍스트 변경 후 수동 재판정
|
||||
function refresh() {
|
||||
updaters.forEach(function (u) { u(); });
|
||||
}
|
||||
|
||||
// 마크업 구동 자동 초기화
|
||||
function init(root) {
|
||||
var $root = root ? $(root) : $(document);
|
||||
@@ -101,7 +150,8 @@
|
||||
RULES: RULES,
|
||||
isValid: isValid,
|
||||
bind: bind,
|
||||
init: init
|
||||
init: init,
|
||||
refresh: refresh
|
||||
};
|
||||
|
||||
$(function () {
|
||||
|
||||
@@ -133,6 +133,7 @@ const customPopups = {
|
||||
// 기본값 설정
|
||||
const title = options.title || '비밀번호 입력';
|
||||
const message = options.message || '계속하려면 비밀번호를 입력해주세요.';
|
||||
const placeholder = options.placeholder || '비밀번호를 입력하세요';
|
||||
const onConfirm = options.onConfirm;
|
||||
const onCancel = options.onCancel;
|
||||
|
||||
@@ -140,8 +141,8 @@ const customPopups = {
|
||||
$('#passwordPopupTitle').text(title);
|
||||
$('#passwordPopupMessage').html(customPopups._sanitizeHtml(message));
|
||||
|
||||
// 입력 필드 및 에러 초기화
|
||||
$('#passwordPopupInput').val('').removeClass('error');
|
||||
// 입력 필드 및 에러 초기화 (placeholder 는 호출부 커스텀 허용)
|
||||
$('#passwordPopupInput').val('').removeClass('error').attr('placeholder', placeholder);
|
||||
$('#passwordPopupError').removeClass('show').text('');
|
||||
|
||||
// 팝업 표시 (modal 구조 사용)
|
||||
@@ -406,8 +407,12 @@ const customPopups = {
|
||||
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') {
|
||||
onConfirm(mobile, notifyConsent);
|
||||
onConfirm(formattedMobile, notifyConsent);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -672,9 +677,36 @@ const customPopups = {
|
||||
|
||||
$('body').css('overflow', 'hidden');
|
||||
|
||||
// 열 때마다 사유/에러 초기화
|
||||
$('#withdrawalReasonInput').val('');
|
||||
$('#withdrawalReasonError').hide();
|
||||
$('#withdrawalReasonInput').off('input.withdrawal').on('input.withdrawal', function () {
|
||||
$('#withdrawalReasonError').hide();
|
||||
});
|
||||
|
||||
$('#withdrawalPopupConfirmButton').off('click').on('click', function () {
|
||||
// 탈퇴 사유 필수 입력
|
||||
var reason = $.trim($('#withdrawalReasonInput').val() || '');
|
||||
if (!reason) {
|
||||
$('#withdrawalReasonError').show();
|
||||
$('#withdrawalReasonInput').focus();
|
||||
return;
|
||||
}
|
||||
$('#withdrawalReasonHidden').val(reason);
|
||||
|
||||
customPopups.hideWithdrawal();
|
||||
$('#withdrawalForm').submit();
|
||||
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
|
||||
$('#withdrawalPopupCancelButton').off('click').on('click', function () {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* TwoFactorAuth.open({
|
||||
* mode: 'login' | 'stepup', // 참고용(서버가 세션으로 판별). 로깅/분기용
|
||||
* purpose: '/myapikey/...', // step-up 대상 보호 경로(로그인은 생략)
|
||||
* purpose: '/clients/...', // step-up 대상 보호 경로(로그인은 생략)
|
||||
* onSuccess: function(res){}, // 검증 성공. login 이면 res.redirect 사용
|
||||
* onCancel: function(reason){}// 닫기/타임아웃/실패로 종료
|
||||
* });
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
'op.tryItOut': { en: 'Try it out', ko: '실행해보기' },
|
||||
'op.cancel': { en: 'Cancel', ko: '취소' },
|
||||
'op.execute': { en: 'Execute', ko: '실행' },
|
||||
'op.clear': { en: 'Clear', ko: '지우기' },
|
||||
'op.clear': { en: 'Clear', ko: 'Reset (응답지우기)' },
|
||||
'op.reset': { en: 'Reset', ko: '초기화' },
|
||||
'op.parameters': { en: 'Parameters', ko: '파라미터' },
|
||||
'op.parameter': { en: 'Parameter', ko: '파라미터' },
|
||||
|
||||
@@ -225,6 +225,18 @@
|
||||
+ '</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 확정 + (검증) + 타이머 시작 + 빈 그리드 표시.
|
||||
// 캡처 단계라 Swagger(React) 의 실행 핸들러보다 먼저 실행됨 → 검증 실패 시
|
||||
// stopImmediatePropagation 으로 native 실행을 차단하고 에러만 렌더한다.
|
||||
@@ -247,6 +259,7 @@
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
renderValidationErrors(result.errors);
|
||||
scrollToGrid(targetOpblock);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -261,6 +274,7 @@
|
||||
grid.classList.remove("djb-error");
|
||||
grid.innerHTML = renderEmpty();
|
||||
}
|
||||
scrollToGrid(targetOpblock);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -225,6 +225,15 @@
|
||||
withObserverPaused(function () { renderInto(panelEl()); });
|
||||
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"]');
|
||||
if (copy) {
|
||||
var code = document.querySelector("#djb-snippet-panel .djb-sn-code");
|
||||
|
||||
@@ -41,6 +41,40 @@
|
||||
.swagger-ui .responses-inner > div { 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) ─────────────── */
|
||||
.swagger-ui #djb-snippet-panel {
|
||||
margin: 16px;
|
||||
|
||||
@@ -598,6 +598,14 @@
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: #cbd5e1 !important;
|
||||
color: #94a3b8 !important;
|
||||
cursor: not-allowed !important;
|
||||
transform: none !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
&.md {
|
||||
font-size: 14px;
|
||||
padding: 11px 45px;
|
||||
|
||||
@@ -478,7 +478,7 @@ select.form-control {
|
||||
|
||||
&:disabled,
|
||||
&.input-readonly {
|
||||
background: $gray-bg;
|
||||
background: #e9ecef !important;
|
||||
color: $text-gray;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -29,15 +29,15 @@
|
||||
line-height: 1;
|
||||
|
||||
&::before {
|
||||
content: "\2022"; // •
|
||||
content: "•";
|
||||
}
|
||||
}
|
||||
|
||||
&.is-idle {
|
||||
color: #888;
|
||||
color: #718096;
|
||||
|
||||
.policy-icon {
|
||||
color: #b5b5b5;
|
||||
color: #a0aec0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,5 +71,5 @@
|
||||
margin: 10px 0 0 0;
|
||||
font-size: 14px;
|
||||
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 {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: stretch;
|
||||
height: 56px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid $tfa-border-2;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
@@ -217,6 +216,8 @@ $tfa-danger: #F4253C;
|
||||
.tfa-code-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
padding: 0 14px;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
|
||||
@@ -913,7 +913,7 @@
|
||||
.nav-menu {
|
||||
display: flex;
|
||||
list-style: none;
|
||||
gap: 28px;
|
||||
gap: 24px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
@@ -925,6 +925,7 @@
|
||||
>.nav-link {
|
||||
background: var(--light-bg);
|
||||
color: var(--primary-blue);
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.sub-menu {
|
||||
@@ -978,7 +979,7 @@
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--text-gray);
|
||||
padding: 8px;
|
||||
padding: 12px;
|
||||
position: relative;
|
||||
transition: var(--transition-smooth);
|
||||
|
||||
|
||||
@@ -34,18 +34,21 @@
|
||||
@use 'components/partners' as *;
|
||||
@use 'components/cta' as *;
|
||||
@use 'components/password-popup' as *;
|
||||
@use 'components/password-policy' as *;
|
||||
@use 'components/header-auth' as *;
|
||||
@use 'components/session-timer' as *;
|
||||
@use 'components/tables' as *;
|
||||
@use 'components/accordion' as *;
|
||||
@use 'components/page-title-banner' as *;
|
||||
@use 'components/alerts' as *;
|
||||
@use 'components/toast' as *;
|
||||
@use 'components/pagination' as *;
|
||||
@use 'components/breadcrumb' as *;
|
||||
@use 'components/test-env-notice' as *;
|
||||
@use 'components/djb-inquiry-comments' as *;
|
||||
@use 'components/board-common' as *;
|
||||
@use 'components/two-factor' as *;
|
||||
@use 'components/password-policy' as *;
|
||||
|
||||
// 5. Page-specific styles
|
||||
@use 'pages/index' as *;
|
||||
|
||||
@@ -9,33 +9,35 @@
|
||||
|
||||
.account-recovery-page {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
padding: 100px 20px;
|
||||
position: relative;
|
||||
min-height: auto;
|
||||
margin: 0;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
|
||||
.account-recovery-container {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
max-width: 650px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
padding: $spacing-lg;
|
||||
}
|
||||
|
||||
.account-recovery-card {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
background: #FFFFFF;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.10);
|
||||
padding: 36px 36px 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
position: relative;
|
||||
|
||||
@media (max-width: 576px) {
|
||||
padding: 0 20px;
|
||||
padding: 24px 20px 20px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,14 +51,13 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
// Tab Navigation (Figma 디자인: 약관 페이지와 동일)
|
||||
// Tab Navigation (Design 2: 클래식 폴더 탭)
|
||||
.account-recovery-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 0;
|
||||
margin-bottom: 28px;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
|
||||
.tab-link {
|
||||
@@ -64,45 +65,46 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 14px 24px;
|
||||
padding: 14px 20px;
|
||||
font-family: $font-family-primary;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #8c959f;
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
color: #adb5bd;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
background: #eceff4;
|
||||
background: #FFFFFF;
|
||||
// 비활성 탭: 하단 border만 (바닥선 역할)
|
||||
border: 1.5px solid transparent;
|
||||
border-bottom: 1.5px solid #dee2e6;
|
||||
border-radius: 0;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
// 왼쪽 탭 둥근 모서리
|
||||
&:first-child {
|
||||
border-radius: 30px 0 0 0;
|
||||
}
|
||||
|
||||
// 오른쪽 탭 둥근 모서리
|
||||
&:last-child {
|
||||
border-radius: 0 30px 0 0;
|
||||
}
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
color: #3ba4ed;
|
||||
background: #e4e8ed;
|
||||
color: $primary-blue;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #FFFFFF;
|
||||
background: #3ba4ed;
|
||||
color: $primary-blue;
|
||||
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) {
|
||||
padding: 12px 16px;
|
||||
font-size: 14px;
|
||||
padding: 12px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Alert Messages
|
||||
.account-alert {
|
||||
width: 100%;
|
||||
@@ -140,73 +142,45 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Form Content Area (Figma: 아이디찾기BG_box)
|
||||
// Form Content Area (세로 스택형, 라벨 없는 심플 스타일)
|
||||
.account-recovery-form {
|
||||
width: 100%;
|
||||
margin-bottom: 0;
|
||||
background: #F6F9FB;
|
||||
padding: 40px;
|
||||
border-radius: 0 0 12px 12px;
|
||||
|
||||
@media (max-width: 576px) {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
margin-bottom: 12px;
|
||||
|
||||
&:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
// 라벨은 숨김 (placeholder로 대체)
|
||||
.form-label {
|
||||
display: flex;
|
||||
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;
|
||||
}
|
||||
display: none;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
flex: 1;
|
||||
height: 40px;
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
padding: 0 16px;
|
||||
font-family: $font-family-primary;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #212529;
|
||||
background: #FFFFFF;
|
||||
border: 1px solid #dadada;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #E2E8F0;
|
||||
border-radius: 10px;
|
||||
outline: none;
|
||||
transition: all 0.3s ease;
|
||||
transition: all 0.25s ease;
|
||||
|
||||
&::placeholder {
|
||||
color: #dadada;
|
||||
color: #B0B8C1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@@ -215,7 +189,7 @@
|
||||
|
||||
&:focus {
|
||||
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 {
|
||||
@@ -231,24 +205,24 @@
|
||||
}
|
||||
|
||||
.form-select {
|
||||
height: 40px;
|
||||
padding: 0 32px 0 16px;
|
||||
height: 48px;
|
||||
padding: 0 24px 0 12px; // padding을 좀 더 좁혀서 내용이 더 잘 보이도록 수정
|
||||
font-family: $font-family-primary;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #515151;
|
||||
background: #FFFFFF;
|
||||
border: 1px solid #dadada;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #E2E8F0;
|
||||
border-radius: 10px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
transition: all 0.25s ease;
|
||||
appearance: none;
|
||||
-webkit-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-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
background-position: right 8px center; // 화살표 아이콘 위치도 조정
|
||||
background-size: 10px 5px;
|
||||
|
||||
&:hover {
|
||||
@@ -257,7 +231,7 @@
|
||||
|
||||
&:focus {
|
||||
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 {
|
||||
@@ -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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
|
||||
.phone-prefix {
|
||||
width: 100px;
|
||||
width: 90px;
|
||||
min-width: 90px; // select가 줄어들지 않도록 고정
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.phone-middle,
|
||||
.phone-last {
|
||||
width: 100px;
|
||||
flex-shrink: 0;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.phone-separator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 8px;
|
||||
height: 1px;
|
||||
background: #515151;
|
||||
color: #B0B8C1;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
width: 10px;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
|
||||
.phone-prefix,
|
||||
.phone-middle,
|
||||
.phone-last {
|
||||
flex: 1;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.phone-separator {
|
||||
width: 6px;
|
||||
&::before {
|
||||
content: '-';
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.phone-prefix,
|
||||
.phone-middle,
|
||||
.phone-last {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
|
||||
.phone-prefix {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.phone-separator {
|
||||
@@ -332,7 +350,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Auth Number Group (Figma: 인증번호+텍스트필드)
|
||||
// Auth Number Group
|
||||
.auth-number-group {
|
||||
margin-top: 0;
|
||||
}
|
||||
@@ -341,95 +359,103 @@
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
|
||||
|
||||
&.mg-y {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
}
|
||||
|
||||
.auth-input {
|
||||
flex: 1;
|
||||
padding-right: 60px;
|
||||
min-width: 0;
|
||||
|
||||
}
|
||||
|
||||
.auth-timer {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
right: 20px; // verify button width + gap
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-family: $font-family-primary;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #ed5b5b;
|
||||
pointer-events: none;
|
||||
|
||||
@media (max-width: 576px) {
|
||||
font-size: 12px;
|
||||
right: 12px;
|
||||
right: 100px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Buttons (Figma: 인증번호 받기/확인)
|
||||
// Buttons: 인증번호 받기 / 확인
|
||||
.auth-request-button,
|
||||
.auth-verify-button {
|
||||
width: 140px;
|
||||
height: 40px;
|
||||
padding: 8px;
|
||||
height: 48px;
|
||||
padding: 0 16px;
|
||||
font-family: $font-family-primary;
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #FFFFFF;
|
||||
background: #a4d6ea;
|
||||
background: #3ba4ed;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
margin: 0;
|
||||
transition: all 0.25s ease;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
|
||||
&:hover {
|
||||
background: darken(#a4d6ea, 5%);
|
||||
background: darken(#3ba4ed, 8%);
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: darken(#a4d6ea, 10%);
|
||||
background: darken(#3ba4ed, 14%);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
height: 44px;
|
||||
font-size: 13px;
|
||||
margin-top: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// Form Actions (Figma: btn_취소, btn_신청)
|
||||
// Form Actions
|
||||
.account-recovery-card .form-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
margin-top: 32px;
|
||||
padding: 32px 0;
|
||||
gap: 12px;
|
||||
margin-top: 28px;
|
||||
padding: 0;
|
||||
border-top: none;
|
||||
background: transparent;
|
||||
|
||||
.cancel-button,
|
||||
.submit-button {
|
||||
width: 160px;
|
||||
height: 40px;
|
||||
padding: 8px;
|
||||
flex: 1;
|
||||
height: 48px;
|
||||
padding: 0 8px;
|
||||
font-family: $font-family-primary;
|
||||
font-size: 14px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
transition: all 0.25s ease;
|
||||
line-height: 1;
|
||||
// <a> 태그 지원
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -438,14 +464,14 @@
|
||||
|
||||
.cancel-button {
|
||||
color: #5f666c;
|
||||
background: #e5e7eb;
|
||||
background: #f0f2f5;
|
||||
|
||||
&:hover {
|
||||
background: darken(#e5e7eb, 5%);
|
||||
background: darken(#f0f2f5, 5%);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: darken(#e5e7eb, 10%);
|
||||
background: darken(#f0f2f5, 10%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,7 +480,9 @@
|
||||
background: #0049B4;
|
||||
|
||||
&:hover {
|
||||
background: darken(#0049B4, 5%);
|
||||
// background: darken(#0049B4, 5%);
|
||||
background: rgb(6 54 125);
|
||||
|
||||
}
|
||||
|
||||
&:active {
|
||||
@@ -468,15 +496,12 @@
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 20px 0;
|
||||
|
||||
.cancel-button,
|
||||
.submit-button {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
font-size: 13px;
|
||||
height: 44px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -521,6 +546,7 @@
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
@@ -545,7 +571,6 @@
|
||||
// Result Page Styles (아이디 찾기 결과 페이지)
|
||||
.account-recovery-result {
|
||||
width: 100%;
|
||||
background: #F6F9FB;
|
||||
padding: 60px 40px;
|
||||
border-radius: 0 0 12px 12px;
|
||||
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') {
|
||||
.account-recovery-page {
|
||||
@@ -710,164 +735,78 @@
|
||||
}
|
||||
|
||||
.account-recovery-container {
|
||||
padding: 20px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.account-recovery-card {
|
||||
padding: 0;
|
||||
padding: 24px 16px 20px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
// Figma: 탭 - 167px × 40px, 14px Bold
|
||||
.account-recovery-tabs {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.tab-link {
|
||||
height: 40px;
|
||||
padding: 10px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
|
||||
&:first-child {
|
||||
border-radius: 8px 0 0 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-radius: 0 8px 0 0;
|
||||
}
|
||||
padding: 9px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
// Figma: 폼 영역
|
||||
.account-recovery-form {
|
||||
padding: 20px;
|
||||
border-radius: 0 0 8px 8px;
|
||||
|
||||
.form-group {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
// Figma: 라벨 - 14px Medium
|
||||
.form-label {
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
// Figma: 입력 필드 - 335px × 40px, border-radius 8px
|
||||
.form-input {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 0 16px;
|
||||
font-size: 12px;
|
||||
border-radius: 8px;
|
||||
|
||||
&::placeholder {
|
||||
color: #8c959f;
|
||||
}
|
||||
height: 44px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
// Figma: 셀렉트 - 40px 높이, 8px radius
|
||||
.form-select {
|
||||
height: 40px;
|
||||
padding: 0 32px 0 12px;
|
||||
font-size: 12px;
|
||||
border-radius: 8px;
|
||||
background-position: right 10px center;
|
||||
height: 44px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
// 모바일: 휴대폰 번호 입력 - 화면 폭 채우기
|
||||
.phone-input-group {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 6px;
|
||||
|
||||
.phone-prefix {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.phone-middle,
|
||||
.phone-last {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.phone-separator {
|
||||
display: flex;
|
||||
width: auto;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
color: #000;
|
||||
|
||||
&::before {
|
||||
content: '-';
|
||||
}
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// 모바일: 인증번호 입력 그룹 - 화면 폭 채우기
|
||||
.auth-input-group {
|
||||
width: 100%;
|
||||
|
||||
.auth-input {
|
||||
width: 100%;
|
||||
padding-right: 60px;
|
||||
}
|
||||
|
||||
// 타이머 - 12px, #ed5b5b
|
||||
.auth-timer {
|
||||
font-size: 12px;
|
||||
right: 12px;
|
||||
right: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
// 모바일: 인증번호 받기/확인 버튼 - 화면 폭 채우기, 다음 줄에 배치
|
||||
.auth-request-button,
|
||||
.auth-verify-button {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
border-radius: 8px;
|
||||
margin-top: 8px;
|
||||
height: 44px;
|
||||
font-size: 13px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
// Figma: 하단 버튼 - 144px × 40px, gap 21px, 가로 배치
|
||||
.account-recovery-card .form-actions {
|
||||
flex-direction: row;
|
||||
gap: 21px;
|
||||
margin-top: 24px;
|
||||
padding: 24px 0;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
|
||||
.cancel-button,
|
||||
.submit-button {
|
||||
flex: 1;
|
||||
min-width: 144px;
|
||||
height: 40px;
|
||||
height: 44px;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -923,4 +862,4 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -726,9 +726,9 @@
|
||||
margin: $spacing-sm 0 0;
|
||||
padding: $spacing-lg;
|
||||
background: $white;
|
||||
border: 1px solid $border-gray;
|
||||
border: none;
|
||||
border-radius: $border-radius-lg;
|
||||
box-shadow: $shadow-sm;
|
||||
box-shadow: none;
|
||||
|
||||
.testbed-app-panel__head {
|
||||
display: flex;
|
||||
@@ -846,6 +846,22 @@
|
||||
}
|
||||
|
||||
// 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 {
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
|
||||
@@ -25,6 +25,12 @@
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.statistics-notice-example {
|
||||
flex-basis: 100%; /* flex-wrap 컨테이너에서 두 번째 줄로 강제 */
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.statistics-notice-latest {
|
||||
color: #0049B4;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -159,7 +159,7 @@
|
||||
|
||||
.form-input:disabled,
|
||||
.input-readonly {
|
||||
background: #ededed;
|
||||
background: #e9ecef !important;
|
||||
}
|
||||
|
||||
.compound-input {
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
background: none;
|
||||
padding: 0 0 $spacing-md 0;
|
||||
border-radius: 0;
|
||||
border-bottom: 2px solid #212529;
|
||||
border-bottom: 1px solid #212529;
|
||||
margin-bottom: $spacing-xl;
|
||||
|
||||
h3 {
|
||||
@@ -124,83 +124,78 @@
|
||||
|
||||
// Info Notice
|
||||
.org-info-notice {
|
||||
border-radius: $border-radius-md;
|
||||
margin-bottom: $spacing-md;
|
||||
background: #FFF5F5;
|
||||
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 {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
.notice-icon-wrapper {
|
||||
display: flex;
|
||||
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;
|
||||
|
||||
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
|
||||
.org-form-group {
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 26px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 13px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@include respond-to('sm') {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.org-form-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
font-size: 15px;
|
||||
font-weight: $font-weight-regular;
|
||||
color: $text-dark;
|
||||
min-width: 160px;
|
||||
padding-top: 10px;
|
||||
font-family: $font-family-primary;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #1A202C;
|
||||
width: auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
.required-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px 8px;
|
||||
background: #3BA4ED;
|
||||
color: $white;
|
||||
font-size: 12px;
|
||||
font-weight: $font-weight-medium;
|
||||
border-radius: 4px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
visibility: hidden;
|
||||
position: relative;
|
||||
width: 10px;
|
||||
|
||||
@include respond-to('sm') {
|
||||
min-width: auto;
|
||||
padding-top: 0;
|
||||
&::after {
|
||||
content: '*';
|
||||
visibility: visible;
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 0;
|
||||
color: #f4253c;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.org-form-input-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -217,6 +212,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
width: 100%;
|
||||
max-width: 650px;
|
||||
|
||||
.org-form-input,
|
||||
.org-compound-input,
|
||||
@@ -249,18 +246,20 @@
|
||||
// Input Fields
|
||||
.org-form-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid $border-gray;
|
||||
border-radius: $border-radius-md;
|
||||
padding: 10px 16px;
|
||||
border: 1px solid #CBD5E0;
|
||||
border-radius: 8px;
|
||||
font-family: $font-family-primary;
|
||||
font-size: 14px;
|
||||
color: $text-dark;
|
||||
transition: $transition-base;
|
||||
background: $white;
|
||||
color: #1A202C;
|
||||
transition: all 0.3s ease;
|
||||
background: #FCFDFD;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $primary-blue;
|
||||
box-shadow: 0 0 0 3px rgba(75, 155, 255, 0.1);
|
||||
border-color: #0049B4; // Brand primary blue
|
||||
box-shadow: 0 0 0 3px rgba(0, 73, 180, 0.15);
|
||||
background: #FFFFFF;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
@@ -278,7 +277,7 @@
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: $text-light;
|
||||
color: #A0AEC0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,9 +288,13 @@
|
||||
gap: $spacing-sm;
|
||||
width: 100%;
|
||||
|
||||
&.phone-input-group {
|
||||
flex: none;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.org-form-input,
|
||||
.org-form-select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -448,18 +451,23 @@
|
||||
}
|
||||
|
||||
.org-btn-check {
|
||||
padding: 8px 16px;
|
||||
padding: 10px 20px;
|
||||
width: auto;
|
||||
min-width: 100px;
|
||||
background: #A4D6EA;
|
||||
color: $white;
|
||||
border: 1px solid #A4D6EA;
|
||||
border-radius: $border-radius-md;
|
||||
border-radius: 8px;
|
||||
font-family: $font-family-primary;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
// transition: all 0.2s ease;
|
||||
background: #3BA4ED;
|
||||
border: 1px solid #3BA4ED;
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background: #008BC0;
|
||||
border-color: #008BC0;
|
||||
color: $white;
|
||||
}
|
||||
|
||||
@@ -548,7 +556,7 @@
|
||||
color: $text-gray;
|
||||
line-height: 1.5;
|
||||
|
||||
& + p {
|
||||
&+p {
|
||||
margin-top: $spacing-xs;
|
||||
}
|
||||
|
||||
@@ -564,12 +572,11 @@
|
||||
.org-action-buttons {
|
||||
display: flex;
|
||||
gap: $spacing-lg;
|
||||
justify-content: center;
|
||||
justify-content: end;
|
||||
margin-top: $spacing-4xl;
|
||||
padding-top: $spacing-3xl;
|
||||
border-top: 1px solid $border-gray;
|
||||
|
||||
.btn {
|
||||
.btn {
|
||||
width: 220px;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
@@ -627,15 +634,18 @@
|
||||
padding: $spacing-sm $spacing-md;
|
||||
border-radius: $border-radius-sm;
|
||||
font-size: $font-size-sm;
|
||||
display: none;
|
||||
|
||||
&.success {
|
||||
background: rgba(107, 207, 127, 0.1);
|
||||
color: $accent-green;
|
||||
display: block;
|
||||
}
|
||||
|
||||
&.error {
|
||||
background: rgba(255, 107, 107, 0.1);
|
||||
color: $accent-orange;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -710,6 +720,7 @@
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
margin-bottom: 90px;
|
||||
font-family: $font-family-primary;
|
||||
}
|
||||
|
||||
// Agree All Section
|
||||
@@ -758,11 +769,11 @@
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
|
||||
&:checked + .agreement-checkbox-custom {
|
||||
&:checked+.agreement-checkbox-custom {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -847,13 +858,15 @@
|
||||
// Agreement Content
|
||||
.agreement-content {
|
||||
margin-top: $spacing-md;
|
||||
background: $main-bg;
|
||||
background: #f9f9f9;
|
||||
border-radius: 10px;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.agreement-scroll {
|
||||
max-height: 300px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
padding: $spacing-lg;
|
||||
// padding: $spacing-lg;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
@@ -879,7 +892,12 @@
|
||||
line-height: 1.8;
|
||||
color: $text-gray;
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
color: $text-dark;
|
||||
margin-top: $spacing-lg;
|
||||
margin-bottom: $spacing-md;
|
||||
@@ -890,7 +908,8 @@
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
ul,
|
||||
ol {
|
||||
margin-left: $spacing-lg;
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
@@ -900,7 +919,8 @@
|
||||
border-collapse: collapse;
|
||||
margin-bottom: $spacing-md;
|
||||
|
||||
th, td {
|
||||
th,
|
||||
td {
|
||||
border: 1px solid $border-gray;
|
||||
padding: $spacing-sm;
|
||||
text-align: left;
|
||||
@@ -947,3 +967,22 @@
|
||||
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;
|
||||
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
|
||||
|
||||
@@ -7,16 +7,14 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
.signup-selection-page {
|
||||
min-height: calc(100vh - 380px); // Account for header and footer
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #EDF9FE; // Light blue background from Figma (same as login)
|
||||
padding: 30px 20px;
|
||||
padding: 100px 20px;
|
||||
position: relative;
|
||||
border-radius: 12px;
|
||||
margin-top: 30px;
|
||||
margin-bottom: 30px;
|
||||
margin-top: 60px;
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
|
||||
.signup-selection-container {
|
||||
@@ -38,118 +36,225 @@
|
||||
}
|
||||
}
|
||||
|
||||
.signup-logo {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
margin-bottom: 15px;
|
||||
.signup-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 40px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
img {
|
||||
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 {
|
||||
font-family: $font-family-primary;
|
||||
font-size: 20px;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
color: #000000;
|
||||
color: #718096;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
line-height: 1;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.signup-buttons {
|
||||
.signup-cards {
|
||||
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;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.signup-btn {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
font-family: $font-family-primary;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #FFFFFF;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
border-radius: 12px;
|
||||
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
color: #1A202C;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
line-height: 1;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.03);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
// Background subtle glow effect on hover
|
||||
&::before {
|
||||
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;
|
||||
}
|
||||
|
||||
.card-icon-wrapper {
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
border-radius: 22px; // Squircle style
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
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);
|
||||
}
|
||||
|
||||
.card-text-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-family: $font-family-primary;
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
margin-top: 0;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.3;
|
||||
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 {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
transform: translateY(-8px);
|
||||
background: #FFFFFF;
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.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;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 26px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.signup-nav-link {
|
||||
font-family: $font-family-primary;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
color: #000000;
|
||||
text-decoration: none;
|
||||
transition: color 0.3s ease;
|
||||
line-height: 1;
|
||||
|
||||
&:hover {
|
||||
color: #0049B4;
|
||||
text-decoration: underline;
|
||||
&::before {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.signup-nav-separator {
|
||||
color: #000000;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
&.individual-card {
|
||||
.card-icon-wrapper {
|
||||
background: linear-gradient(135deg, #F0F5FF 0%, #D8E5FF 100%);
|
||||
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)
|
||||
@include respond-to('sm') {
|
||||
.signup-selection-page {
|
||||
// Figma: 전체 배경 #ebfbff, 풀 높이
|
||||
background: #ebfbff;
|
||||
min-height: calc(100vh - 44px);
|
||||
padding: 40px 20px;
|
||||
@@ -163,73 +268,83 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.signup-selection-card {
|
||||
padding: 0;
|
||||
}
|
||||
.signup-header {
|
||||
margin-bottom: 30px;
|
||||
|
||||
.signup-logo {
|
||||
// Figma: 64px × 64px
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin-bottom: 24px;
|
||||
.signup-title-row {
|
||||
margin-bottom: 8px;
|
||||
|
||||
.signup-title-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.signup-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.signup-message {
|
||||
// Figma: 14px Regular, #212529
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #212529;
|
||||
margin-bottom: 80px;
|
||||
line-height: 26px;
|
||||
margin-bottom: 40px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.signup-buttons {
|
||||
// Figma: 버튼 간격 24px
|
||||
.signup-cards {
|
||||
max-width: 335px;
|
||||
gap: 24px;
|
||||
margin-bottom: 80px;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.signup-btn {
|
||||
// Figma: 335px × 44px, border-radius 8px
|
||||
height: 44px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
.signup-card-item {
|
||||
padding: 24px 20px;
|
||||
border-radius: 12px;
|
||||
flex-direction: row;
|
||||
text-align: left;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
svg {
|
||||
width: 14px;
|
||||
height: 16px;
|
||||
.card-icon-wrapper {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
margin-bottom: 0;
|
||||
flex-shrink: 0;
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
text-align: center;
|
||||
.card-text-wrapper {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.signup-navigation {
|
||||
// Figma: 14px Regular, #515961, gap 12px
|
||||
gap: 12px;
|
||||
.signup-navigation-box {
|
||||
max-width: 260px;
|
||||
height: 38px;
|
||||
|
||||
.signup-nav-link {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #515961;
|
||||
}
|
||||
|
||||
.signup-nav-separator {
|
||||
font-size: 14px;
|
||||
color: #515961;
|
||||
.signup-nav-btn {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@
|
||||
<!-- Header -->
|
||||
<div class="api-market-header">
|
||||
<div class="api-market-title">
|
||||
<h1>서비스</h1>
|
||||
<h1 th:text="${apiSpecInfo.apiGroupName}">그룹명</h1>
|
||||
<h2 th:text="${apiSpecInfo.apiName}">API 이름</h2>
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,8 +69,9 @@
|
||||
<div class="tab-content" th:classappend="${activeTab == 'testbed'} ? '' : 'active'" id="api-info-tab">
|
||||
<!-- API Overview Card (Merged with Additional Information) -->
|
||||
<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>
|
||||
<button type="button" class="btn-action-primary md api-apply-btn">API 사용 신청</button>
|
||||
</div>
|
||||
<div class="api-overview-header">
|
||||
<div class="api-method-badge" th:text="${apiSpecInfo.apiMethod}">GET</div>
|
||||
@@ -142,6 +143,11 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 하단 중앙 API 사용 신청 -->
|
||||
<div class="api-apply-footer">
|
||||
<button type="button" class="btn-action-primary api-apply-btn">API 사용 신청</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Testbed Tab Content -->
|
||||
@@ -275,6 +281,8 @@
|
||||
// 활성 탭은 서버가 결정하고, 테스트베드 탭이 활성이면서 인증된 경우에만
|
||||
// Swagger UI를 초기화한다(실제 초기화는 하단에서 실행 — const 선언 이후).
|
||||
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 isAuthenticated = /*[[${authenticated}]]*/ false;
|
||||
const testbedActive = (activeTab === 'testbed' && isAuthenticated);
|
||||
@@ -364,6 +372,8 @@
|
||||
onComplete: function() {
|
||||
// 단일 API 페이지 — 상시 요청 스니펫 패널 마운트
|
||||
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,
|
||||
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: 앱 인증 정보 자동 주입 =====
|
||||
const DEFAULT_TOKEN_API_ID = 'default-token-api-spec';
|
||||
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) {
|
||||
switch (reason) {
|
||||
case 'ANONYMOUS': return '로그인 후 본인 앱의 인증 정보를 사용할 수 있습니다.';
|
||||
@@ -438,43 +526,136 @@
|
||||
}
|
||||
appsSelect.disabled = false;
|
||||
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) {
|
||||
const opt = document.createElement('option');
|
||||
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);
|
||||
});
|
||||
})
|
||||
.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'
|
||||
+ '&client_id=' + encodeURIComponent(clientId)
|
||||
+ '&client_secret=' + encodeURIComponent(clientSecret)
|
||||
+ '&scope=api';
|
||||
// 토큰 발급 프록시 여부(djb.gateway.token-use-proxy). false 면 토큰 URL 로 브라우저 직접 호출.
|
||||
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' };
|
||||
if (!direct) {
|
||||
headers['original-url'] = gw.tokenUrl;
|
||||
headers['original-url'] = tokenUrl;
|
||||
headers['X-XSRF-TOKEN'] = /*[[${_csrf.token}]]*/ '';
|
||||
}
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
body: body
|
||||
}).then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (data) { return data ? data.access_token : null; });
|
||||
}).then(function (r) {
|
||||
// 실패 시에도 프록시(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) {
|
||||
const gw = window.__djbGateway || {};
|
||||
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') {
|
||||
fetchOAuthToken(secret.clientId, secret.clientSecret, gw).then(function (token) {
|
||||
if (!token) return;
|
||||
fetchOAuthToken(secret.clientId, clientSecret, tokenUrl).then(function (token) {
|
||||
window.ui.authActions.authorize({
|
||||
djbOAuth: {
|
||||
name: 'djbOAuth',
|
||||
@@ -482,15 +663,21 @@
|
||||
value: 'Bearer ' + token
|
||||
}
|
||||
});
|
||||
refreshSnippetPanel();
|
||||
}).catch(function (e) {
|
||||
console.error('토큰 발급 실패', e);
|
||||
resetAppSelection();
|
||||
showTestbedError(e && e.message ? e.message : '인증 토큰 발급 중 오류가 발생했습니다.');
|
||||
});
|
||||
} else if (secret.authType === 'API_KEY') {
|
||||
window.ui.authActions.authorize({
|
||||
djbApiKey: {
|
||||
name: 'djbApiKey',
|
||||
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;
|
||||
fetch((/*[[@{/djb/testbed/auth/credentials/}]]*/ '/djb/testbed/auth/credentials/')
|
||||
+ 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); })
|
||||
.catch(function (e) { console.error('앱 인증정보 주입 실패', e); });
|
||||
.catch(function (e) {
|
||||
console.error('앱 인증정보 주입 실패', e);
|
||||
resetAppSelection();
|
||||
showTestbedError(e && e.message ? e.message : '앱 인증정보 주입 중 오류가 발생했습니다.');
|
||||
});
|
||||
}
|
||||
|
||||
// 테스트베드 탭 활성 + 인증 상태일 때만 Swagger UI/앱 인증 컨텍스트 초기화
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
</div>
|
||||
</section>
|
||||
<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="password-change-wrapper">
|
||||
<h2 class="page-outer-title">본인 확인</h2>
|
||||
|
||||
@@ -1,148 +1,145 @@
|
||||
<!doctype html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kbank_base_layout}">
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kbank_base_layout}">
|
||||
|
||||
<body>
|
||||
<section layout:fragment="contentFragment" class="content">
|
||||
<div class="content_wrap">
|
||||
<div class="account-recovery-container">
|
||||
<div class="account-recovery-card">
|
||||
<!-- 탭 메뉴 -->
|
||||
<div class="account-recovery-tabs">
|
||||
<a href="#tab1" class="tab-link active" data-tab="tab1">아이디 찾기</a>
|
||||
<a href="#tab2" class="tab-link" data-tab="tab2">비밀번호 초기화</a>
|
||||
</div>
|
||||
<section layout:fragment="contentFragment" class="content">
|
||||
<div class="content_wrap">
|
||||
<div class="account-recovery-container">
|
||||
<div class="account-recovery-card">
|
||||
|
||||
<!-- 아이디 찾기 폼 -->
|
||||
<div id="tab1" class="account-recovery-form">
|
||||
<form id="findIdForm" role="form" name="findIdForm" th:action="@{/find_id}" method="post" data-form-type="findId">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
|
||||
<input type="hidden" id="findId-phoneNumber" name="phoneNumber">
|
||||
<!-- 탭 메뉴 (pill 스타일) -->
|
||||
<div class="account-recovery-tabs">
|
||||
<a href="#tab1" class="tab-link active" data-tab="tab1">아이디 찾기</a>
|
||||
<a href="#tab2" class="tab-link" data-tab="tab2">비밀번호 초기화</a>
|
||||
</div>
|
||||
|
||||
<!-- 성명 입력 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">성명</label>
|
||||
<input type="text" name="name" class="form-input" placeholder="이름" required>
|
||||
</div>
|
||||
<!-- 아이디 찾기 폼 -->
|
||||
<div id="tab1" class="account-recovery-form">
|
||||
<form id="findIdForm" role="form" name="findIdForm" th:action="@{/find_id}" method="post"
|
||||
data-form-type="findId">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
|
||||
<input type="hidden" id="findId-phoneNumber" name="phoneNumber">
|
||||
|
||||
<!-- 휴대폰 번호 입력 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">휴대폰 번호</label>
|
||||
<div class="phone-input-group">
|
||||
<select class="form-select phone-prefix select_mobile_prefix">
|
||||
<option>선택</option>
|
||||
<option>010</option>
|
||||
<option>011</option>
|
||||
<option>016</option>
|
||||
<option>017</option>
|
||||
<option>018</option>
|
||||
</select>
|
||||
<span class="phone-separator">-</span>
|
||||
<input type="number" name="phoneMiddle" class="form-input" placeholder="앞자리" required>
|
||||
<span class="phone-separator">-</span>
|
||||
<input type="number" name="phoneLast" class="form-input" placeholder="뒷자리" required>
|
||||
<button type="button" class="auth-request-button btn_auth">인증번호 받기</button>
|
||||
<!-- 성명 입력 -->
|
||||
<div class="form-group">
|
||||
<input type="text" name="name" class="form-input" placeholder="성명" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 인증번호 입력 -->
|
||||
<div class="form-group auth-number-container" id="findId-authNumberContainer" style="display: none;">
|
||||
<label class="form-label">인증번호 입력</label>
|
||||
<div class="auth-input-group">
|
||||
<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>
|
||||
<button type="button" class="auth-verify-button btn_verify_auth">확인</button>
|
||||
<!-- 휴대폰 번호 입력 -->
|
||||
<div class="form-group">
|
||||
<div class="phone-input-group">
|
||||
<select class="form-select phone-prefix select_mobile_prefix">
|
||||
<option>선택</option>
|
||||
<option>010</option>
|
||||
<option>011</option>
|
||||
<option>016</option>
|
||||
<option>017</option>
|
||||
<option>018</option>
|
||||
</select>
|
||||
<span class="phone-separator">-</span>
|
||||
<input type="number" name="phoneMiddle" class="form-input" placeholder="앞자리" required>
|
||||
<span class="phone-separator">-</span>
|
||||
<input type="number" name="phoneLast" class="form-input" placeholder="뒷자리" required>
|
||||
<button type="button" class="auth-request-button btn_auth">인증번호 받기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 제출 버튼 -->
|
||||
<div class="form-actions">
|
||||
<a class="cancel-button btn_cancel">취소</a>
|
||||
<button type="submit" class="submit-button">아이디 찾기</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 비밀번호 초기화 폼 -->
|
||||
<div id="tab2" class="account-recovery-form">
|
||||
<form id="resetPasswordForm" role="form" name="resetPasswordForm" th:action="@{/reset_password}" 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">
|
||||
|
||||
<!-- 인증 방식 선택 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">초기화 방법</label>
|
||||
<div style="display: flex; gap: 20px; align-items: center;">
|
||||
<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>
|
||||
휴대폰 번호
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 6px; font-size: 14px; margin: 0;">
|
||||
<input type="radio" id="radio_email" name="resetMethod" value="email_id">
|
||||
이메일 아이디
|
||||
</label>
|
||||
<!-- 인증번호 입력 -->
|
||||
<div class="form-group auth-number-container" id="findId-authNumberContainer" style="display: none;">
|
||||
<div class="auth-input-group">
|
||||
<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>
|
||||
<button type="button" class="auth-verify-button btn_verify_auth">확인</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 성명 입력 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">성명</label>
|
||||
<input type="text" name="name" class="form-input" placeholder="성명" required>
|
||||
</div>
|
||||
|
||||
<!-- 아이디 입력 (이메일) -->
|
||||
<div class="form-group" id="emailInputContainer" style="display: none;">
|
||||
<label class="form-label">이메일 아이디</label>
|
||||
<input type="text" name="emailId" class="form-input" placeholder="이메일 아이디">
|
||||
</div>
|
||||
|
||||
<!-- 휴대폰 번호 입력 -->
|
||||
<div class="form-group" id="phoneInputContainer">
|
||||
<label class="form-label">휴대폰 번호</label>
|
||||
<div class="phone-input-group">
|
||||
<select class="form-select phone-prefix select_mobile_prefix">
|
||||
<option>선택</option>
|
||||
<option>010</option>
|
||||
<option>011</option>
|
||||
<option>016</option>
|
||||
<option>017</option>
|
||||
<option>018</option>
|
||||
</select>
|
||||
<span class="phone-separator">-</span>
|
||||
<input type="number" name="phoneMiddle" class="form-input" placeholder="앞자리" required>
|
||||
<span class="phone-separator">-</span>
|
||||
<input type="number" name="phoneLast" class="form-input" placeholder="뒷자리" required>
|
||||
<button type="button" class="auth-request-button btn_auth">인증번호 받기</button>
|
||||
<!-- 제출 버튼 -->
|
||||
<div class="form-actions">
|
||||
<a class="cancel-button btn_cancel">취소</a>
|
||||
<button type="submit" class="submit-button">아이디 찾기</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 인증번호 입력 -->
|
||||
<div class="form-group auth-number-container" id="authNumberContainer" style="display: none;">
|
||||
<label class="form-label">인증번호 입력</label>
|
||||
<div class="auth-input-group">
|
||||
<input type="number" class="form-input auth-input auth-number-input" placeholder="SMS 인증번호" required>
|
||||
<span class="auth-timer" id="certify_time">03:00</span>
|
||||
<button type="button" class="auth-verify-button btn_verify_auth">확인</button>
|
||||
<!-- 비밀번호 초기화 폼 -->
|
||||
<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">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
|
||||
<input type="hidden" id="resetPassword-phoneNumber" name="phoneNumber">
|
||||
|
||||
<!-- 인증 방식 선택 (pill 버튼 스타일) -->
|
||||
<div class="form-group reset-method-group">
|
||||
<div class="reset-method-tabs">
|
||||
<label class="reset-method-tab">
|
||||
<input type="radio" id="radio_hp" name="resetMethod" value="hp" checked>
|
||||
<span>휴대폰 번호</span>
|
||||
</label>
|
||||
<label class="reset-method-tab">
|
||||
<input type="radio" id="radio_email" name="resetMethod" value="email_id">
|
||||
<span>이메일 아이디</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 제출 버튼 -->
|
||||
<div class="form-actions">
|
||||
<a class="cancel-button btn_cancel">취소</a>
|
||||
<button type="submit" class="submit-button">비밀번호 초기화</button>
|
||||
</div>
|
||||
</form>
|
||||
<!-- 성명 입력 -->
|
||||
<div class="form-group">
|
||||
<input type="text" name="name" class="form-input" placeholder="성명" required>
|
||||
</div>
|
||||
|
||||
<!-- 아이디 입력 (이메일) -->
|
||||
<div class="form-group" id="emailInputContainer" style="display: none;">
|
||||
<input type="text" name="emailId" class="form-input" placeholder="이메일 아이디">
|
||||
</div>
|
||||
|
||||
<!-- 휴대폰 번호 입력 -->
|
||||
<div class="form-group" id="phoneInputContainer">
|
||||
<div class="phone-input-group">
|
||||
<select class="form-select phone-prefix select_mobile_prefix">
|
||||
<option>선택</option>
|
||||
<option>010</option>
|
||||
<option>011</option>
|
||||
<option>016</option>
|
||||
<option>017</option>
|
||||
<option>018</option>
|
||||
</select>
|
||||
<span class="phone-separator">-</span>
|
||||
<input type="number" name="phoneMiddle" class="form-input" placeholder="앞자리" required>
|
||||
<span class="phone-separator">-</span>
|
||||
<input type="number" name="phoneLast" class="form-input" placeholder="뒷자리" required>
|
||||
<button type="button" class="auth-request-button btn_auth">인증번호 받기</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 인증번호 입력 -->
|
||||
<div class="form-group auth-number-container" id="authNumberContainer" style="display: none;">
|
||||
<div class="auth-input-group mg-y">
|
||||
<input type="number" class="form-input auth-input auth-number-input" placeholder="SMS 인증번호" required>
|
||||
<span class="auth-timer" id="certify_time">03:00</span>
|
||||
<button type="button" class="auth-verify-button btn_verify_auth">확인</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 제출 버튼 -->
|
||||
<div class="form-actions">
|
||||
<a class="cancel-button btn_cancel">취소</a>
|
||||
<button type="submit" class="submit-button">비밀번호 초기화</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</body>
|
||||
<th:block layout:fragment="contentScript">
|
||||
<script th:inline="javascript">
|
||||
$(document).ready(function() {
|
||||
$(document).ready(function () {
|
||||
|
||||
// 탭 전환 기능
|
||||
$('.tab-link').on('click', function(e) {
|
||||
$('.tab-link').on('click', function (e) {
|
||||
e.preventDefault();
|
||||
const tabId = $(this).data('tab');
|
||||
|
||||
@@ -158,7 +155,7 @@
|
||||
});
|
||||
|
||||
// 초기화 방법 선택에 따라 폼 표시 변경
|
||||
$('input[name="resetMethod"]').change(function() {
|
||||
$('input[name="resetMethod"]').change(function () {
|
||||
if (this.value === 'hp') {
|
||||
$('#phoneInputContainer').show();
|
||||
$('#emailInputContainer').hide();
|
||||
@@ -169,7 +166,7 @@
|
||||
});
|
||||
|
||||
// 취소 버튼 클릭 시 로그인 페이지로 이동
|
||||
$('.btn_cancel').on('click', function() {
|
||||
$('.btn_cancel').on('click', function () {
|
||||
location.href = '/login';
|
||||
});
|
||||
|
||||
@@ -189,7 +186,7 @@
|
||||
}
|
||||
|
||||
// 인증번호 요청 버튼 이벤트 리스너
|
||||
$(".btn_auth").on("click", function(e) {
|
||||
$(".btn_auth").on("click", function (e) {
|
||||
e.preventDefault();
|
||||
let form = $(this).closest("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();
|
||||
let form = $(this).closest("form");
|
||||
let authNumberInput = form.find(".auth-number-input");
|
||||
@@ -216,4 +213,5 @@
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
@@ -1,116 +1,98 @@
|
||||
<!doctype html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
|
||||
|
||||
<body>
|
||||
<section layout:fragment="title">
|
||||
<div class="page-title-banner">
|
||||
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||
<h1>아이디 찾기</h1>
|
||||
</div>
|
||||
</section>
|
||||
<th:block layout:fragment="contentFragment">
|
||||
<div class="account-recovery-page">
|
||||
<div class="account-recovery-container">
|
||||
<div class="account-recovery-card">
|
||||
<!-- Tab Navigation -->
|
||||
<div class="account-recovery-tabs">
|
||||
<a href="#" class="tab-link active">아이디찾기</a>
|
||||
<a th:href="@{/account_recovery(tab='resetPassword')}" class="tab-link">비밀번호 초기화</a>
|
||||
</div>
|
||||
|
||||
<!-- Alert Messages -->
|
||||
<div id="alertContainer"></div>
|
||||
|
||||
<!-- 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}">
|
||||
|
||||
<!-- Name Field -->
|
||||
<div class="form-group">
|
||||
<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>
|
||||
<section layout:fragment="title">
|
||||
<div class="page-title-banner">
|
||||
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||
<h1>아이디 찾기</h1>
|
||||
</div>
|
||||
</section>
|
||||
<th:block layout:fragment="contentFragment">
|
||||
<div class="account-recovery-page">
|
||||
<div class="account-recovery-container">
|
||||
<div class="account-recovery-card">
|
||||
<!-- Tab Navigation -->
|
||||
<div class="account-recovery-tabs">
|
||||
<a href="#" class="tab-link active">아이디 찾기</a>
|
||||
<a th:href="@{/account_recovery(tab='resetPassword')}" class="tab-link">비밀번호 초기화</a>
|
||||
</div>
|
||||
|
||||
<!-- Phone Number Fields -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">휴대폰 번호<span class="required-badge">필수</span></label>
|
||||
<div class="org-input-row">
|
||||
<div class="org-compound-input">
|
||||
<select id="phonePrefix" class="org-form-select phone-prefix" required>
|
||||
<option value="">선택</option>
|
||||
<option value="010">010</option>
|
||||
<option value="011">011</option>
|
||||
<option value="016">016</option>
|
||||
<option value="017">017</option>
|
||||
<option value="018">018</option>
|
||||
</select>
|
||||
<span class="separator">-</span>
|
||||
<input type="tel"
|
||||
id="phoneMiddle"
|
||||
name="phoneMiddle"
|
||||
maxlength="4"
|
||||
class="form-input phone-number"
|
||||
placeholder="1234"
|
||||
pattern="[0-9]*"
|
||||
inputmode="numeric"
|
||||
required>
|
||||
<span class="separator">-</span>
|
||||
<input type="tel"
|
||||
id="phoneLast"
|
||||
name="phoneLast"
|
||||
maxlength="4"
|
||||
class="form-input phone-number"
|
||||
placeholder="1234"
|
||||
pattern="[0-9]*"
|
||||
inputmode="numeric"
|
||||
required>
|
||||
<!-- Alert Messages -->
|
||||
<div id="alertContainer"></div>
|
||||
|
||||
<!-- 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}">
|
||||
|
||||
<!-- Name Field -->
|
||||
<div class="form-group">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Phone Number Fields -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">휴대폰 번호<span class="required-badge">필수</span></label>
|
||||
<div class="org-input-row">
|
||||
<div class="org-compound-input">
|
||||
<select id="phonePrefix" class="org-form-select phone-prefix" required>
|
||||
<option value="">선택</option>
|
||||
<option value="010">010</option>
|
||||
<option value="011">011</option>
|
||||
<option value="016">016</option>
|
||||
<option value="017">017</option>
|
||||
<option value="018">018</option>
|
||||
</select>
|
||||
<span class="separator">-</span>
|
||||
<input type="tel" id="phoneMiddle" name="phoneMiddle" maxlength="4" class="form-input phone-number"
|
||||
placeholder="1234" pattern="[0-9]*" inputmode="numeric" required>
|
||||
<span class="separator">-</span>
|
||||
<input type="tel" id="phoneLast" name="phoneLast" maxlength="4" class="form-input phone-number"
|
||||
placeholder="1234" pattern="[0-9]*" inputmode="numeric" required>
|
||||
</div>
|
||||
<button type="button" class="org-btn-check btn_auth" id="requestAuthButton">
|
||||
인증번호 받기
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="btn org-btn-check btn_auth" id="requestAuthButton">
|
||||
인증번호 받기
|
||||
</div>
|
||||
|
||||
<!-- Auth Number Input -->
|
||||
<div class="form-group auth-number-group" id="authNumberGroup" style="display: none;">
|
||||
<label for="authNumber" class="form-label">인증번호 입력<span class="required-badge">필수</span></label>
|
||||
<div class="auth-input-group mg-y">
|
||||
<input type="text" id="authNumber" 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>
|
||||
</div>
|
||||
<button type="button" class="auth-verify-button" id="verifyAuthButton">
|
||||
인증번호 확인
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Submit Buttons -->
|
||||
<div class="form-actions">
|
||||
<button type="button" class="cancel-button" id="cancelButton">취소</button>
|
||||
<button type="submit" class="submit-button" id="submitButton" form="accountForm">아이디 찾기</button>
|
||||
</div>
|
||||
|
||||
<!-- Auth Number Input -->
|
||||
<div class="form-group auth-number-group" id="authNumberGroup" style="display: none;">
|
||||
<label for="authNumber" class="form-label">인증번호 입력<span class="required-badge">필수</span></label>
|
||||
<div class="auth-input-group">
|
||||
<input type="text"
|
||||
id="authNumber"
|
||||
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>
|
||||
</div>
|
||||
<button type="button" class="auth-verify-button" id="verifyAuthButton">
|
||||
인증번호 확인
|
||||
</button>
|
||||
<!-- Loading State -->
|
||||
<div class="loading-overlay" id="loadingOverlay">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Submit Buttons -->
|
||||
<div class="form-actions">
|
||||
<button type="button" class="cancel-button" id="cancelButton">취소</button>
|
||||
<button type="submit" class="submit-button" id="submitButton" form="accountForm">아이디 찾기</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div class="loading-overlay" id="loadingOverlay">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</th:block>
|
||||
</th:block>
|
||||
</body>
|
||||
<th:block layout:fragment="contentScript">
|
||||
<script th:inline="javascript">
|
||||
$(document).ready(function() {
|
||||
const errorMsg = [[${error}]];
|
||||
$(document).ready(function () {
|
||||
const errorMsg = [[${ error }]];
|
||||
if (errorMsg) {
|
||||
customPopups.showAlert(errorMsg, 'error');
|
||||
}
|
||||
@@ -167,7 +149,7 @@
|
||||
}
|
||||
|
||||
// 인증번호 요청 버튼
|
||||
$('#requestAuthButton').on('click', function() {
|
||||
$('#requestAuthButton').on('click', function () {
|
||||
const mobileNumber = combineMobileNumber();
|
||||
|
||||
if (!mobileNumber) {
|
||||
@@ -185,7 +167,7 @@
|
||||
mobileNumber: mobileNumber,
|
||||
_csrf: $('input[name="_csrf"]').val()
|
||||
},
|
||||
success: function(response) {
|
||||
success: function (response) {
|
||||
$('#loadingOverlay').fadeOut(200);
|
||||
|
||||
if (response.valid) {
|
||||
@@ -210,7 +192,7 @@
|
||||
customPopups.showAlert(response.message || '인증번호 발송에 실패했습니다.', 'error');
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
error: function () {
|
||||
$('#loadingOverlay').fadeOut(200);
|
||||
customPopups.showAlert('처리 중 오류가 발생했습니다. 다시 시도해주세요.', 'error');
|
||||
}
|
||||
@@ -218,7 +200,7 @@
|
||||
});
|
||||
|
||||
// 인증번호 확인 버튼
|
||||
$('#verifyAuthButton').on('click', function() {
|
||||
$('#verifyAuthButton').on('click', function () {
|
||||
if (!isAuthNumberRequested) {
|
||||
customPopups.showAlert('휴대폰 번호 인증을 먼저 진행해주세요.', 'error');
|
||||
return;
|
||||
@@ -242,7 +224,7 @@
|
||||
authNumber: authNumber,
|
||||
_csrf: $('input[name="_csrf"]').val()
|
||||
},
|
||||
success: function(response) {
|
||||
success: function (response) {
|
||||
$('#loadingOverlay').fadeOut(200);
|
||||
|
||||
if (response.valid) {
|
||||
@@ -260,7 +242,7 @@
|
||||
customPopups.showAlert(response.message || '인증번호가 일치하지 않습니다.', 'error');
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
error: function () {
|
||||
$('#loadingOverlay').fadeOut(200);
|
||||
customPopups.showAlert('처리 중 오류가 발생했습니다. 다시 시도해주세요.', 'error');
|
||||
}
|
||||
@@ -268,7 +250,7 @@
|
||||
});
|
||||
|
||||
// 폼 제출
|
||||
$('#accountForm').on('submit', function(e) {
|
||||
$('#accountForm').on('submit', function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const userName = $('#userName').val().trim();
|
||||
@@ -298,7 +280,7 @@
|
||||
});
|
||||
|
||||
// 취소 버튼
|
||||
$('#cancelButton').on('click', function() {
|
||||
$('#cancelButton').on('click', function () {
|
||||
window.location.href = '/login';
|
||||
});
|
||||
|
||||
@@ -330,7 +312,7 @@
|
||||
}
|
||||
|
||||
// 기존 휴대폰 번호 복원
|
||||
$(document).ready(function() {
|
||||
$(document).ready(function () {
|
||||
const savedMobileNumber = $('#mobileNumber').val();
|
||||
if (savedMobileNumber) {
|
||||
const parts = savedMobileNumber.split('-');
|
||||
@@ -343,4 +325,5 @@
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
@@ -32,7 +32,7 @@
|
||||
<div class="found-users-list">
|
||||
<div class="found-user-item" th:each="user, stat : ${foundUsers}">
|
||||
<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">
|
||||
(<th:block th:text="${#temporals.format(user.createdDate, 'yyyy.MM.dd')}">2024.01.01</th:block> 가입)
|
||||
</span>
|
||||
|
||||
@@ -149,34 +149,21 @@
|
||||
});
|
||||
}
|
||||
|
||||
// 로그인 전 중복 접속 확인 → 중복 시 기존 세션 강제 로그아웃 여부 질의
|
||||
function checkDuplicateAndLogin(form) {
|
||||
var loginId = form.id.value;
|
||||
$.ajax({
|
||||
url: /*[[@{/api/session/check-duplicate}]]*/ '/api/session/check-duplicate',
|
||||
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 {
|
||||
form.submit();
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
// 중복 체크 실패 시 로그인은 그대로 진행
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
// 동시 접속 확인은 비밀번호 검증(1차 인증) 통과 후 서버가 안내한다
|
||||
// (2FA pending 또는 /login?duplicate=1 대기 상태에서 duplicateInfo 모델로 수신).
|
||||
function duplicateConfirmMessage(info) {
|
||||
return '해당 계정은 이미 다른 곳(' + info.ipAddress + ')에서 접속 중입니다.<br>'
|
||||
+ '접속 시각: ' + info.loginTime + '<br><br>'
|
||||
+ '기존 접속을 해제하고 로그인하시겠습니까?';
|
||||
}
|
||||
|
||||
// 세션 CSRF 헤더 (meta[name=_csrf]) — 인증 후 확인/취소 POST 용
|
||||
function csrfHeaders() {
|
||||
var t = document.querySelector('meta[name="_csrf"]');
|
||||
var h = document.querySelector('meta[name="_csrf_header"]');
|
||||
var headers = {};
|
||||
headers[h ? h.getAttribute('content') : 'X-XSRF-TOKEN'] = t ? t.getAttribute('content') : '';
|
||||
return headers;
|
||||
}
|
||||
|
||||
function fnInit() {
|
||||
@@ -242,7 +229,7 @@
|
||||
customPopups.showAlert('[[#{login.passLengthShort}]]');
|
||||
} else {
|
||||
refreshCsrfAndThen(form, function () {
|
||||
checkDuplicateAndLogin(form);
|
||||
form.submit();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -260,9 +247,12 @@
|
||||
}
|
||||
});
|
||||
|
||||
// 로그인 2FA: 1차 인증 통과 후 pending 상태면 추가 인증 팝업 자동 오픈
|
||||
// 1차 인증(비밀번호) 통과 후 흐름: [동시 접속 확인] → [2FA] 순서.
|
||||
var twoFactorPending = [[${twoFactorPending}]];
|
||||
if (twoFactorPending && typeof TwoFactorAuth !== 'undefined') {
|
||||
var duplicateConfirmPending = [[${duplicateConfirmPending}]];
|
||||
var duplicateInfo = [[${duplicateInfo}]];
|
||||
|
||||
function openLoginTwoFactor() {
|
||||
TwoFactorAuth.open({
|
||||
mode: 'login',
|
||||
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();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,140 +1,111 @@
|
||||
<!doctype html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
|
||||
|
||||
<body>
|
||||
<section layout:fragment="title">
|
||||
<div class="page-title-banner">
|
||||
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||
<h1>비밀번호 초기화</h1>
|
||||
</div>
|
||||
</section>
|
||||
<th:block layout:fragment="contentFragment">
|
||||
<div class="account-recovery-page">
|
||||
<div class="account-recovery-container">
|
||||
<div class="account-recovery-card">
|
||||
<!-- Tab Navigation -->
|
||||
<div class="account-recovery-tabs">
|
||||
<a th:href="@{/account_recovery(tab='findId')}" class="tab-link">아이디찾기</a>
|
||||
<a href="#" class="tab-link active">비밀번호 초기화</a>
|
||||
</div>
|
||||
|
||||
<!-- Alert Messages -->
|
||||
<div id="alertContainer"></div>
|
||||
|
||||
<!-- Account Recovery Form -->
|
||||
<form id="accountForm" role="form" name="accountForm" th:action="@{/reset_password}" method="post" 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}">
|
||||
|
||||
<!-- Name Field -->
|
||||
<div class="form-group">
|
||||
<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>
|
||||
<section layout:fragment="title">
|
||||
<div class="page-title-banner">
|
||||
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||
<h1>비밀번호 초기화</h1>
|
||||
</div>
|
||||
</section>
|
||||
<th:block layout:fragment="contentFragment">
|
||||
<div class="account-recovery-page">
|
||||
<div class="account-recovery-container">
|
||||
<div class="account-recovery-card">
|
||||
<!-- Tab Navigation -->
|
||||
<div class="account-recovery-tabs">
|
||||
<a th:href="@{/account_recovery(tab='findId')}" class="tab-link">아이디찾기</a>
|
||||
<a href="#" class="tab-link active">비밀번호 초기화</a>
|
||||
</div>
|
||||
|
||||
<!-- Email ID Field -->
|
||||
<div class="form-group">
|
||||
<label for="loginId" class="form-label">이메일 아이디<span class="required-badge">필수</span></label>
|
||||
<input type="email"
|
||||
id="loginId"
|
||||
name="loginId"
|
||||
th:value="${loginId}"
|
||||
class="form-input"
|
||||
placeholder="이메일 아이디"
|
||||
required>
|
||||
</div>
|
||||
<!-- Alert Messages -->
|
||||
<div id="alertContainer"></div>
|
||||
|
||||
<!-- Phone Number Fields -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">휴대폰 번호<span class="required-badge">필수</span></label>
|
||||
<div class="org-input-row">
|
||||
<div class="org-compound-input">
|
||||
<select id="phonePrefix" class="org-form-select phone-prefix" required>
|
||||
<option value="">선택</option>
|
||||
<option value="010">010</option>
|
||||
<option value="011">011</option>
|
||||
<option value="016">016</option>
|
||||
<option value="017">017</option>
|
||||
<option value="018">018</option>
|
||||
</select>
|
||||
<span class="separator">-</span>
|
||||
<input type="tel"
|
||||
id="phoneMiddle"
|
||||
name="phoneMiddle"
|
||||
maxlength="4"
|
||||
class="form-input phone-number"
|
||||
placeholder="1234"
|
||||
pattern="[0-9]*"
|
||||
inputmode="numeric"
|
||||
required>
|
||||
<span class="separator">-</span>
|
||||
<input type="tel"
|
||||
id="phoneLast"
|
||||
name="phoneLast"
|
||||
maxlength="4"
|
||||
class="form-input phone-number"
|
||||
placeholder="1234"
|
||||
pattern="[0-9]*"
|
||||
inputmode="numeric"
|
||||
required>
|
||||
<!-- Account Recovery Form -->
|
||||
<form id="accountForm" role="form" name="accountForm" th:action="@{/reset_password}" method="post"
|
||||
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}">
|
||||
|
||||
<!-- Name Field -->
|
||||
<div class="form-group">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Email ID Field -->
|
||||
<div class="form-group">
|
||||
<label for="loginId" class="form-label">이메일 아이디<span class="required-badge">필수</span></label>
|
||||
<input type="email" id="loginId" name="loginId" th:value="${loginId}" class="form-input"
|
||||
placeholder="이메일 아이디" required>
|
||||
</div>
|
||||
|
||||
<!-- Phone Number Fields -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">휴대폰 번호<span class="required-badge">필수</span></label>
|
||||
<div class="org-input-row">
|
||||
<div class="org-compound-input">
|
||||
<select id="phonePrefix" class="org-form-select phone-prefix" required>
|
||||
<option value="">선택</option>
|
||||
<option value="010">010</option>
|
||||
<option value="011">011</option>
|
||||
<option value="016">016</option>
|
||||
<option value="017">017</option>
|
||||
<option value="018">018</option>
|
||||
</select>
|
||||
<span class="separator">-</span>
|
||||
<input type="tel" id="phoneMiddle" name="phoneMiddle" maxlength="4" class="form-input phone-number"
|
||||
placeholder="1234" pattern="[0-9]*" inputmode="numeric" required>
|
||||
<span class="separator">-</span>
|
||||
<input type="tel" id="phoneLast" name="phoneLast" maxlength="4" class="form-input phone-number"
|
||||
placeholder="1234" pattern="[0-9]*" inputmode="numeric" required>
|
||||
</div>
|
||||
<button type="button" class="org-btn-check btn_auth" id="requestAuthButton">
|
||||
인증번호 받기
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="btn org-btn-check btn_auth" id="requestAuthButton">
|
||||
인증번호 받기
|
||||
</div>
|
||||
|
||||
<!-- Auth Number Input -->
|
||||
<div class="form-group auth-number-group" id="authNumberGroup" style="display: none;">
|
||||
<label for="authNumber" class="form-label">인증번호 입력<span class="required-badge">필수</span></label>
|
||||
<div class="auth-input-group mg-y">
|
||||
<input type="text" id="authNumber" 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>
|
||||
</div>
|
||||
<button type="button" class="auth-verify-button" id="verifyAuthButton">
|
||||
인증번호 확인
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Submit Buttons -->
|
||||
<div class="form-actions">
|
||||
<button type="button" class="cancel-button" id="cancelButton">취소</button>
|
||||
<button type="submit" class="submit-button" id="submitButton" form="accountForm">비밀번호 초기화</button>
|
||||
</div>
|
||||
|
||||
<!-- Auth Number Input -->
|
||||
<div class="form-group auth-number-group" id="authNumberGroup" style="display: none;">
|
||||
<label for="authNumber" class="form-label">인증번호 입력<span class="required-badge">필수</span></label>
|
||||
<div class="auth-input-group">
|
||||
<input type="text"
|
||||
id="authNumber"
|
||||
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>
|
||||
</div>
|
||||
<button type="button" class="auth-verify-button" id="verifyAuthButton">
|
||||
인증번호 확인
|
||||
</button>
|
||||
<!-- Loading State -->
|
||||
<div class="loading-overlay" id="loadingOverlay">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Submit Buttons -->
|
||||
<div class="form-actions">
|
||||
<button type="button" class="cancel-button" id="cancelButton">취소</button>
|
||||
<button type="submit" class="submit-button" id="submitButton" form="accountForm">비밀번호 초기화</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div class="loading-overlay" id="loadingOverlay">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</th:block>
|
||||
</th:block>
|
||||
</body>
|
||||
<th:block layout:fragment="contentScript">
|
||||
<script th:inline="javascript">
|
||||
$(document).ready(function() {
|
||||
const errorMsg = [[${error}]];
|
||||
$(document).ready(function () {
|
||||
const errorMsg = [[${ error }]];
|
||||
if (errorMsg) {
|
||||
customPopups.showAlert(errorMsg, 'error');
|
||||
}
|
||||
|
||||
const successMsg = [[${success}]];
|
||||
const successMsg = [[${ success }]];
|
||||
if (successMsg) {
|
||||
customPopups.showAlert(successMsg, 'success');
|
||||
}
|
||||
@@ -191,7 +162,7 @@
|
||||
}
|
||||
|
||||
// 인증번호 요청 버튼
|
||||
$('#requestAuthButton').on('click', function() {
|
||||
$('#requestAuthButton').on('click', function () {
|
||||
const userName = $('#userName').val().trim();
|
||||
if (!userName) {
|
||||
customPopups.showAlert('성명을 입력해주세요.', 'error');
|
||||
@@ -226,7 +197,7 @@
|
||||
mobileNumber: mobileNumber,
|
||||
_csrf: $('input[name="_csrf"]').val()
|
||||
},
|
||||
success: function(response) {
|
||||
success: function (response) {
|
||||
$('#loadingOverlay').fadeOut(200);
|
||||
|
||||
if (response.valid) {
|
||||
@@ -251,7 +222,7 @@
|
||||
customPopups.showAlert(response.message || '인증번호 발송에 실패했습니다.', 'error');
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
error: function () {
|
||||
$('#loadingOverlay').fadeOut(200);
|
||||
customPopups.showAlert('처리 중 오류가 발생했습니다. 다시 시도해주세요.', 'error');
|
||||
}
|
||||
@@ -259,7 +230,7 @@
|
||||
});
|
||||
|
||||
// 인증번호 확인 버튼
|
||||
$('#verifyAuthButton').on('click', function() {
|
||||
$('#verifyAuthButton').on('click', function () {
|
||||
if (!isAuthNumberRequested) {
|
||||
customPopups.showAlert('휴대폰 번호 인증을 먼저 진행해주세요.', 'error');
|
||||
return;
|
||||
@@ -283,7 +254,7 @@
|
||||
authNumber: authNumber,
|
||||
_csrf: $('input[name="_csrf"]').val()
|
||||
},
|
||||
success: function(response) {
|
||||
success: function (response) {
|
||||
$('#loadingOverlay').fadeOut(200);
|
||||
|
||||
if (response.valid) {
|
||||
@@ -301,7 +272,7 @@
|
||||
customPopups.showAlert(response.message || '인증번호가 일치하지 않습니다.', 'error');
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
error: function () {
|
||||
$('#loadingOverlay').fadeOut(200);
|
||||
customPopups.showAlert('처리 중 오류가 발생했습니다. 다시 시도해주세요.', 'error');
|
||||
}
|
||||
@@ -309,7 +280,7 @@
|
||||
});
|
||||
|
||||
// 폼 제출
|
||||
$('#accountForm').on('submit', function(e) {
|
||||
$('#accountForm').on('submit', function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const userName = $('#userName').val().trim();
|
||||
@@ -351,7 +322,7 @@
|
||||
});
|
||||
|
||||
// 취소 버튼
|
||||
$('#cancelButton').on('click', function() {
|
||||
$('#cancelButton').on('click', function () {
|
||||
window.location.href = '/login';
|
||||
});
|
||||
|
||||
@@ -383,7 +354,7 @@
|
||||
}
|
||||
|
||||
// 기존 휴대폰 번호 복원
|
||||
$(document).ready(function() {
|
||||
$(document).ready(function () {
|
||||
const savedMobileNumber = $('#mobileNumber').val();
|
||||
if (savedMobileNumber) {
|
||||
const parts = savedMobileNumber.split('-');
|
||||
@@ -396,4 +367,5 @@
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
@@ -17,7 +17,7 @@
|
||||
<p class="hero-subtitle">세상의 모든 서비스</p>
|
||||
<h2 class="hero-title">DJBank API가<br>함께 합니다.</h2>
|
||||
</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 class="hero-image-content">
|
||||
<!-- Inline SVG Tech Illustration -->
|
||||
@@ -327,8 +327,8 @@
|
||||
DJBank API Portal은 기업이 혁신적인 금융 서비스를 쉽고 신속하게 개발하고 구현할 수 있도록 엄선된 API 명세와 직관적인 샌드박스 테스트 환경을 무상으로 지원합니다.
|
||||
</p>
|
||||
<div class="action-buttons">
|
||||
<a href="#" 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="@{/service/intro}" class="action-btn btn-secondary">처음 만나는 DJBank API</a>
|
||||
<a th:href="@{/partnership}" class="action-btn btn-primary">피드백 / 개선요청 <i class="bi bi-patch-question"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-image-box">
|
||||
@@ -607,6 +607,7 @@
|
||||
pendingInvitation: [[${ session.pendingInvitation }]],
|
||||
pendingInvitationToken: [[${ session.pendingInvitationToken }]],
|
||||
pendingInvitationOrgName: [[${ session.pendingInvitationOrgName }]],
|
||||
needClientRegister: [[${ needClientRegister }]],
|
||||
sessionSuccessMsg: [[${ session.success }]],
|
||||
redirectUrl: [[${ session.redirectUrl }]]
|
||||
});
|
||||
|
||||
@@ -21,10 +21,10 @@
|
||||
<div class="app-management-content">
|
||||
<!-- Header -->
|
||||
<div class="app-management-header">
|
||||
<h2>인증 키 관리</h2>
|
||||
<h2>클라이언트/API 신청 관리</h2>
|
||||
<div sec:authorize="hasRole('ROLE_API_KEY_REQUEST')">
|
||||
<button type="button" class="btn-action-primary md" id="requestApiKey">
|
||||
앱 생성
|
||||
클라이언트 생성
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -35,7 +35,7 @@
|
||||
<!-- App Requests (Pending) -->
|
||||
<th:block th:if="${appRequests != null and !appRequests.isEmpty()}">
|
||||
<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 -->
|
||||
<div class="app-card-icon-box">
|
||||
@@ -85,7 +85,7 @@
|
||||
<!-- API Keys (Approved/Inactive) -->
|
||||
<th:block th:if="${apiKeys != null and !apiKeys.isEmpty()}">
|
||||
<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 -->
|
||||
<div class="app-card-icon-box">
|
||||
@@ -156,7 +156,7 @@
|
||||
keysToRemove.forEach(key => sessionStorage.removeItem(key));
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -22,12 +22,12 @@
|
||||
<div class="step1-wrap">
|
||||
|
||||
<!-- Title -->
|
||||
<h2 class="s1-title">앱 수정</h2>
|
||||
<h2 class="s1-title">클라이언트 정보 - 변경</h2>
|
||||
|
||||
<!-- Progress Steps Card -->
|
||||
<div class="s1-progress-card">
|
||||
<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-circle">
|
||||
<svg class="s1-step-svg" width="26" height="26" viewBox="0 0 24 24" fill="none"
|
||||
@@ -39,7 +39,7 @@
|
||||
</svg>
|
||||
</div>
|
||||
<span class="s1-step-num">1단계</span>
|
||||
<span class="s1-step-name">앱 정보수정</span>
|
||||
<span class="s1-step-name">클라이언트 정보수정</span>
|
||||
</div>
|
||||
|
||||
<div class="s1-step-line"></div>
|
||||
@@ -71,7 +71,7 @@
|
||||
</svg>
|
||||
</div>
|
||||
<span class="s1-step-num">3단계</span>
|
||||
<span class="s1-step-name">완료</span>
|
||||
<span class="s1-step-name">변경 신청 완료</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -84,48 +84,12 @@
|
||||
|
||||
<!-- 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">
|
||||
|
||||
<!-- Hidden 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">
|
||||
<label class="s1-label">앱 이름 <span class="s1-required">*</span></label>
|
||||
@@ -143,13 +107,6 @@
|
||||
</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">
|
||||
<label class="s1-label">화이트 리스트 <span class="s1-required">*</span></label>
|
||||
@@ -175,12 +132,48 @@
|
||||
<input type="hidden" id="ipWhitelist" name="ipWhitelist" value="">
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<!-- 다음 버튼 -->
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -211,7 +204,8 @@
|
||||
removeBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
function addIpAddress() {
|
||||
function addIpAddress(refocus) {
|
||||
if (refocus === undefined) refocus = true;
|
||||
const ipInput = document.getElementById('ipWhitelistInput');
|
||||
const ipValue = ipInput.value.trim();
|
||||
|
||||
@@ -234,7 +228,7 @@
|
||||
ipAddresses.push(ipValue);
|
||||
renderIpList();
|
||||
ipInput.value = '';
|
||||
ipInput.focus();
|
||||
if (refocus) ipInput.focus();
|
||||
}
|
||||
|
||||
function removeIpAddress(ip) {
|
||||
@@ -349,15 +343,24 @@
|
||||
}
|
||||
|
||||
// IP input Enter key
|
||||
ipInput.addEventListener('keypress', function (e) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); addIpAddress(); }
|
||||
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 경로: 재포커스 안 함
|
||||
});
|
||||
// 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.addEventListener('submit', function (e) {
|
||||
const name = document.getElementById('appName').value.trim();
|
||||
const desc = textarea.value.trim();
|
||||
const url = document.getElementById('callbackUrl').value.trim();
|
||||
|
||||
if (!name) {
|
||||
e.preventDefault();
|
||||
@@ -372,15 +375,6 @@
|
||||
textarea.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (url) {
|
||||
try { new URL(url); } catch (_) {
|
||||
e.preventDefault();
|
||||
customPopups.showAlert('올바른 URL 형식이 아닙니다.\n예: https://example.com/callback');
|
||||
document.getElementById('callbackUrl').focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -23,12 +23,12 @@
|
||||
<div class="step2-wrap">
|
||||
|
||||
<!-- Title -->
|
||||
<h2 class="s2-title">앱 수정</h2>
|
||||
<h2 class="s2-title">클라이언트 정보 - 변경</h2>
|
||||
|
||||
<!-- Progress Steps Card -->
|
||||
<div class="s1-progress-card">
|
||||
<div class="s1-steps">
|
||||
<!-- Step 1: 앱 정보수정 (completed) -->
|
||||
<!-- Step 1: 클라이언트 정보수정 (completed) -->
|
||||
<div class="s1-step">
|
||||
<div class="s1-step-circle">
|
||||
<!-- 입력폼 아이콘 -->
|
||||
@@ -44,7 +44,7 @@
|
||||
</svg>
|
||||
</div>
|
||||
<span class="s1-step-num">1단계</span>
|
||||
<span class="s1-step-name">앱 정보수정</span>
|
||||
<span class="s1-step-name">클라이언트 정보수정</span>
|
||||
</div>
|
||||
|
||||
<div class="s1-step-line"></div>
|
||||
@@ -86,13 +86,13 @@
|
||||
</svg>
|
||||
</div>
|
||||
<span class="s1-step-num">3단계</span>
|
||||
<span class="s1-step-name">앱 수정 완료</span>
|
||||
<span class="s1-step-name">변경 신청 완료</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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 속성으로 주입 -->
|
||||
<input type="hidden" name="clientId" th:value="${apiKeyModification.clientId}" form="apiSelectorForm"/>
|
||||
@@ -111,6 +111,38 @@
|
||||
</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>
|
||||
|
||||
<!-- 화면 전체 오버레이/플로팅은 body 직속(pagePopups)으로 렌더 → wrapper transform·overflow 영향 없이 뷰포트 기준 중앙 정렬 -->
|
||||
|
||||
@@ -24,12 +24,12 @@
|
||||
<div class="step3-wrap">
|
||||
|
||||
<!-- Title -->
|
||||
<h2 class="s3-title">앱 수정</h2>
|
||||
<h2 class="s3-title">클라이언트 정보 - 변경</h2>
|
||||
|
||||
<!-- Progress Steps Card -->
|
||||
<div class="s1-progress-card">
|
||||
<div class="s1-steps">
|
||||
<!-- Step 1: 앱 정보수정 (form/input icon) -->
|
||||
<!-- Step 1: 클라이언트 정보수정 (form/input icon) -->
|
||||
<div class="s1-step">
|
||||
<div class="s1-step-circle">
|
||||
<!-- 입력폼 아이콘 -->
|
||||
@@ -45,7 +45,7 @@
|
||||
</svg>
|
||||
</div>
|
||||
<span class="s1-step-num">1단계</span>
|
||||
<span class="s1-step-name">앱 정보수정</span>
|
||||
<span class="s1-step-name">클라이언트 정보수정</span>
|
||||
</div>
|
||||
|
||||
<div class="s1-step-line"></div>
|
||||
@@ -87,7 +87,7 @@
|
||||
</svg>
|
||||
</div>
|
||||
<span class="s1-step-num">3단계</span>
|
||||
<span class="s1-step-name">앱 수정 완료</span>
|
||||
<span class="s1-step-name">변경 신청 완료</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -151,7 +151,7 @@
|
||||
</div>
|
||||
|
||||
<div class="s3-message-wrapper">
|
||||
<h1 class="s3-success-title">앱 수정이 완료되었습니다.</h1>
|
||||
<h1 class="s3-success-title">클라이언트 변경 신청이 완료되었습니다.</h1>
|
||||
<p class="s3-success-desc">
|
||||
<span class="s3-highlight">담당자 승인 후 변경 사항이 적용됩니다.</span>
|
||||
<br>
|
||||
@@ -161,7 +161,7 @@
|
||||
|
||||
<!-- Bottom Actions -->
|
||||
<div class="s3-actions">
|
||||
<a href="/myapikey" class="s3-btn-complete">
|
||||
<a href="/clients" class="s3-btn-complete">
|
||||
완료
|
||||
</a>
|
||||
</div>
|
||||
@@ -179,10 +179,10 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="s3-actions">
|
||||
<a href="/myapikey" class="s3-btn-retry">
|
||||
<a href="/clients" class="s3-btn-retry">
|
||||
다시 시도
|
||||
</a>
|
||||
<a href="/myapikey" class="s3-btn-list">
|
||||
<a href="/clients" class="s3-btn-list">
|
||||
목록으로
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<div class="step1-wrap">
|
||||
|
||||
<!-- Title -->
|
||||
<h2 class="s1-title">앱생성</h2>
|
||||
<h2 class="s1-title">클라이언트 정보 - 신규</h2>
|
||||
|
||||
<!-- Progress Steps Card -->
|
||||
<div class="s1-progress-card">
|
||||
@@ -84,7 +84,7 @@
|
||||
</svg>
|
||||
</div>
|
||||
<span class="s1-step-num">3단계</span>
|
||||
<span class="s1-step-name">앱 생성 완료</span>
|
||||
<span class="s1-step-name">클라이언트 신청 완료</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -97,30 +97,9 @@
|
||||
|
||||
<!-- 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">
|
||||
|
||||
<!-- 앱 아이콘 -->
|
||||
<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">
|
||||
<label class="s1-label">앱 이름 <span class="s1-required">*</span></label>
|
||||
@@ -171,6 +150,27 @@
|
||||
<input type="hidden" id="ipWhitelist" name="ipWhitelist" value="">
|
||||
</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>
|
||||
</div>
|
||||
|
||||
@@ -202,7 +202,8 @@
|
||||
removeBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
function addIpAddress() {
|
||||
function addIpAddress(refocus) {
|
||||
if (refocus === undefined) refocus = true;
|
||||
const ipInput = document.getElementById('ipWhitelistInput');
|
||||
const ipValue = ipInput.value.trim();
|
||||
|
||||
@@ -225,7 +226,7 @@
|
||||
ipAddresses.push(ipValue);
|
||||
renderIpList();
|
||||
ipInput.value = '';
|
||||
ipInput.focus();
|
||||
if (refocus) ipInput.focus();
|
||||
}
|
||||
|
||||
function removeIpAddress(ip) {
|
||||
@@ -355,9 +356,21 @@
|
||||
});
|
||||
}
|
||||
|
||||
// IP input Enter key
|
||||
ipInput.addEventListener('keypress', function (e) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); addIpAddress(); }
|
||||
// IP input Enter/Tab key — 추가 버튼을 지나쳐도 입력값을 등록한다.
|
||||
// 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); }
|
||||
});
|
||||
|
||||
// 포커스 아웃 시 자동 추가/검증 (사용자가 "추가" 버튼을 지나치는 경우 대응).
|
||||
// "추가" 버튼으로 포커스 이동 시엔 버튼 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
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<div class="step2-wrap">
|
||||
|
||||
<!-- Title -->
|
||||
<h2 class="s2-title">앱생성</h2>
|
||||
<h2 class="s2-title">클라이언트 정보 - 신규</h2>
|
||||
|
||||
<!-- Progress Steps Card -->
|
||||
<div class="s1-progress-card">
|
||||
@@ -86,14 +86,14 @@
|
||||
</svg>
|
||||
</div>
|
||||
<span class="s1-step-num">3단계</span>
|
||||
<span class="s1-step-name">앱 생성 완료</span>
|
||||
<span class="s1-step-name">클라이언트 신청 완료</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API 선택 공용 모듈 -->
|
||||
<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 -->
|
||||
<div class="s2-actions">
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<div class="step3-wrap">
|
||||
|
||||
<!-- Title -->
|
||||
<h2 class="s3-title">앱생성</h2>
|
||||
<h2 class="s3-title">클라이언트 정보 - 신규</h2>
|
||||
|
||||
<!-- Progress Steps Card -->
|
||||
<div class="s1-progress-card">
|
||||
@@ -87,7 +87,7 @@
|
||||
</svg>
|
||||
</div>
|
||||
<span class="s1-step-num">3단계</span>
|
||||
<span class="s1-step-name">앱 생성 완료</span>
|
||||
<span class="s1-step-name">클라이언트 신청 완료</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -151,7 +151,7 @@
|
||||
</div>
|
||||
|
||||
<div class="s3-message-wrapper">
|
||||
<h1 class="s3-success-title">앱 생성이 완료되었습니다.</h1>
|
||||
<h1 class="s3-success-title">클라이언트 신청이 완료되었습니다.</h1>
|
||||
<p class="s3-success-desc">
|
||||
담당자 앱 승인 후 <span class="s3-highlight">[앱 정보]</span> 화면에서
|
||||
<br>
|
||||
@@ -161,7 +161,7 @@
|
||||
|
||||
<!-- Bottom Actions -->
|
||||
<div class="s3-actions">
|
||||
<a href="/myapikey" class="s3-btn-complete">
|
||||
<a href="/clients" class="s3-btn-complete">
|
||||
완료
|
||||
</a>
|
||||
</div>
|
||||
@@ -179,10 +179,10 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="s3-actions">
|
||||
<a href="/myapikey/register/step1" class="s3-btn-retry">
|
||||
<a href="/clients/register/step1" class="s3-btn-retry">
|
||||
다시 시도
|
||||
</a>
|
||||
<a href="/myapikey" class="s3-btn-list">
|
||||
<a href="/clients" class="s3-btn-list">
|
||||
목록으로
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -158,13 +158,13 @@
|
||||
</div>
|
||||
|
||||
<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}">
|
||||
<button type="submit" class="btn_del">취소</button>
|
||||
</form>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
<div class="tabs">
|
||||
<ul class="tab_nav tab_bt">
|
||||
<li class="active">
|
||||
<a th:href="@{/myapikey/api_key_request/history}">개발</a>
|
||||
<a th:href="@{/clients/api_key_request/history}">개발</a>
|
||||
</li>
|
||||
<li>
|
||||
<a th:href="@{/myapikey/api_key_request/prod_history}">운영</a>
|
||||
<a th:href="@{/clients/api_key_request/prod_history}">운영</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tab active">
|
||||
@@ -49,7 +49,7 @@
|
||||
<tbody>
|
||||
<tr th:each="request, status : ${requests}">
|
||||
<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>
|
||||
<td th:text="${request.approval.approvalStatus.description}">1</td>
|
||||
<td th:text="${#temporals.format(request.approval.createdDate, 'yyyy-MM-dd')}"></td>
|
||||
@@ -85,7 +85,7 @@
|
||||
<tbody>
|
||||
<tr th:each="request, status : ${requests}">
|
||||
<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="${#temporals.format(request.approval.createdDate, 'yyyy-MM-dd')}"></td>
|
||||
<td th:text="${#temporals.format(request.approval.approvalDate, 'yyyy-MM-dd')}"></td>
|
||||
@@ -98,7 +98,7 @@
|
||||
</div>
|
||||
</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"/>
|
||||
</form>
|
||||
</div>
|
||||
@@ -112,7 +112,7 @@
|
||||
<script>
|
||||
function fn_select_page(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();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -231,7 +231,7 @@
|
||||
신청 취소
|
||||
</button>
|
||||
<!-- List Button (gray) -->
|
||||
<a th:href="@{/myapikey}" class="dt-btn-gray">
|
||||
<a th:href="@{/clients}" class="dt-btn-gray">
|
||||
목록
|
||||
</a>
|
||||
</div>
|
||||
@@ -289,7 +289,7 @@
|
||||
$('.loading-overlay').show();
|
||||
|
||||
$.ajax({
|
||||
url: '/myapikey/api_key_request/cancel',
|
||||
url: '/clients/api_key_request/cancel',
|
||||
type: 'POST',
|
||||
data: { id: requestId },
|
||||
dataType: 'json',
|
||||
@@ -299,7 +299,7 @@
|
||||
}).done(function (response) {
|
||||
if (response.success) {
|
||||
alert(response.message || '신청이 취소되었습니다.');
|
||||
window.location.href = /*[[@{/myapikey}]]*/ '/myapikey';
|
||||
window.location.href = /*[[@{/clients}]]*/ '/clients';
|
||||
} else {
|
||||
alert(response.message || '신청 취소 중 오류가 발생했습니다.');
|
||||
$('.loading-overlay').hide();
|
||||
|
||||
@@ -1,218 +1,206 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
|
||||
<body>
|
||||
<th:block th:fragment="commonUserInfo">
|
||||
<div class="form-row">
|
||||
<div class="form-label-wrapper label-offset">
|
||||
<span class="form-label-text">이메일 아이디</span>
|
||||
</div>
|
||||
<div class="form-field-wrapper">
|
||||
<input type="text" th:value="${loginId}" name="loginId" class="form-input input-readonly"
|
||||
placeholder="이메일" disabled="disabled">
|
||||
<input type="hidden" th:value="${loginId}" name="loginId">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-label-wrapper label-offset">
|
||||
<span class="form-label-text">성명</span> <span class="required-badge">필수</span>
|
||||
<body>
|
||||
<th:block th:fragment="commonUserInfo">
|
||||
<div class="form-row">
|
||||
<div class="form-label-wrapper">
|
||||
<span class="form-label-text">이메일 아이디</span>
|
||||
</div>
|
||||
<div class="form-field-wrapper">
|
||||
<input type="text" th:value="${loginId}" name="loginId" class="form-input input-readonly"
|
||||
placeholder="이메일" disabled="disabled" style="background: #ededed !important;">
|
||||
<input type="hidden" th:value="${loginId}" name="loginId">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-field-wrapper">
|
||||
<input type="text" th:value="${userName}" id="userName" name="userName" class="form-input"
|
||||
placeholder="성명">
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-label-wrapper">
|
||||
<span class="form-label-text">성명</span> <span class="required-badge">필수</span>
|
||||
</div>
|
||||
<div class="form-field-wrapper">
|
||||
<input type="text" th:value="${userName}" id="userName" name="userName" class="form-input">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-label-wrapper label-offset">
|
||||
<span class="form-label-text">휴대폰 번호</span> <span class="required-badge">필수</span>
|
||||
</div>
|
||||
<div class="form-field-wrapper input-with-button"
|
||||
th:with="hasHyphen=${mobileNumber != null and #strings.contains(mobileNumber, '-')},
|
||||
<div class="form-row">
|
||||
<div class="form-label-wrapper">
|
||||
<span class="form-label-text">휴대폰 번호</span> <span class="required-badge">필수</span>
|
||||
</div>
|
||||
<div class="form-field-wrapper input-with-button"
|
||||
th:with="hasHyphen=${mobileNumber != null and #strings.contains(mobileNumber, '-')},
|
||||
parts=${hasHyphen ? #strings.arraySplit(mobileNumber, '-') : null},
|
||||
len=${mobileNumber != null ? #strings.length(mobileNumber) : 0},
|
||||
mobilePrefix=${hasHyphen ? (parts != null and #arrays.length(parts) > 0 ? parts[0] : '') : (len >= 3 ? #strings.substring(mobileNumber, 0, 3) : '')},
|
||||
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) : ''))}">
|
||||
<div class="compound-input">
|
||||
<input type="text"
|
||||
th:value="${mobilePrefix}"
|
||||
name="mobilePrefix"
|
||||
class="form-input input-readonly"
|
||||
maxlength="3"
|
||||
disabled="disabled">
|
||||
<span class="separator">-</span>
|
||||
<input type="text"
|
||||
th:value="${mobileMiddle}"
|
||||
name="mobileMiddle"
|
||||
class="form-input input-readonly"
|
||||
maxlength="4"
|
||||
disabled="disabled">
|
||||
<span class="separator">-</span>
|
||||
<input type="text"
|
||||
th:value="${mobileLast}"
|
||||
name="mobileLast"
|
||||
class="form-input input-readonly"
|
||||
maxlength="4"
|
||||
disabled="disabled">
|
||||
<div class="compound-input">
|
||||
<input type="text" th:value="${mobilePrefix}" name="mobilePrefix" class="form-input input-readonly"
|
||||
maxlength="3" disabled="disabled" style="background: #ededed !important;">
|
||||
<span class="separator">-</span>
|
||||
<input type="text" th:value="${mobileMiddle}" name="mobileMiddle" class="form-input input-readonly"
|
||||
maxlength="4" disabled="disabled" style="background: #ededed !important;">
|
||||
<span class="separator">-</span>
|
||||
<input type="text" th:value="${mobileLast}" name="mobileLast" class="form-input input-readonly"
|
||||
maxlength="4" disabled="disabled" style="background: #ededed !important;">
|
||||
</div>
|
||||
<button type="button" class="btn-input-action btn-change change_mobile_phone"
|
||||
id="changePhoneBtn">변경</button>
|
||||
</div>
|
||||
<button type="button" class="btn-input-action btn-change change_mobile_phone" id="changePhoneBtn">변경</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row" id="newPhoneNumberContainer" style="display: none;">
|
||||
<div class="form-label-wrapper label-offset">
|
||||
<span class="form-label-text">새 휴대폰 번호</span> <span class="required-badge">필수</span>
|
||||
</div>
|
||||
<div class="form-field-wrapper input-with-button">
|
||||
<div class="compound-input">
|
||||
<select class="form-select phone-number" id="phoneMobile" name="newMobilePrefix">
|
||||
<option value="">선택</option>
|
||||
<option value="010">010</option>
|
||||
<option value="011">011</option>
|
||||
<option value="016">016</option>
|
||||
<option value="017">017</option>
|
||||
<option value="018">018</option>
|
||||
<option value="019">019</option>
|
||||
</select>
|
||||
<span class="separator">-</span>
|
||||
<input type="text" id="newMobileMiddle" name="newMobileMiddle" class="form-input phone-number"
|
||||
maxlength="4" pattern="\d*" inputmode="numeric" placeholder="앞자리"/>
|
||||
<span class="separator">-</span>
|
||||
<input type="text" id="newMobileLast" name="newMobileLast" class="form-input phone-number"
|
||||
maxlength="4" pattern="\d*" inputmode="numeric" placeholder="뒷자리"/>
|
||||
<input type="hidden" value="" name="mobileNumber" id="mobileNumber">
|
||||
<div class="form-row" id="newPhoneNumberContainer" style="display: none;">
|
||||
<div class="form-label-wrapper">
|
||||
<span class="form-label-text">새 휴대폰 번호</span> <span class="required-badge">필수</span>
|
||||
</div>
|
||||
<button type="button" class="btn-input-action btn-auth btn_auth">
|
||||
인증번호 받기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row" id="authNumberContainer" style="display: none;">
|
||||
<div class="form-label-wrapper label-offset">
|
||||
<span class="form-label-text">인증번호 입력</span> <span class="required-badge">필수</span>
|
||||
</div>
|
||||
<div class="form-field-wrapper input-with-button">
|
||||
<div class="auth-input-group">
|
||||
<input type="text" id="authNumber" name="authNumber" maxlength="6" pattern="[0-9]*"
|
||||
inputmode="numeric" class="form-input"
|
||||
placeholder="SMS 인증번호 6자리">
|
||||
<span class="auth-timer" id="certify_time">03:00</span>
|
||||
<div class="form-field-wrapper input-with-button">
|
||||
<div class="compound-input">
|
||||
<select class="form-select phone-number" id="phoneMobile" name="newMobilePrefix">
|
||||
<option value="">선택</option>
|
||||
<option value="010">010</option>
|
||||
<option value="011">011</option>
|
||||
<option value="016">016</option>
|
||||
<option value="017">017</option>
|
||||
<option value="018">018</option>
|
||||
<option value="019">019</option>
|
||||
</select>
|
||||
<span class="separator">-</span>
|
||||
<input type="text" id="newMobileMiddle" name="newMobileMiddle" class="form-input phone-number"
|
||||
maxlength="4" pattern="\d*" inputmode="numeric" placeholder="앞자리" />
|
||||
<span class="separator">-</span>
|
||||
<input type="text" id="newMobileLast" name="newMobileLast" class="form-input phone-number"
|
||||
maxlength="4" pattern="\d*" inputmode="numeric" placeholder="뒷자리" />
|
||||
<input type="hidden" value="" name="mobileNumber" id="mobileNumber">
|
||||
</div>
|
||||
<button type="button" class="btn-input-action btn-auth btn_auth ">
|
||||
인증번호 받기
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="btn-input-action btn-auth btn_verify_auth">
|
||||
인증번호 확인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="authNumber-validation"></div>
|
||||
</th:block>
|
||||
<th:block th:fragment="commonUserInfoScript">
|
||||
<script>
|
||||
// 휴대폰 번호 조합 함수
|
||||
function combineMobileNumber() {
|
||||
let mobileSelect = document.getElementById('phoneMobile');
|
||||
if (!mobileSelect) {
|
||||
console.error('Mobile select element not found');
|
||||
return null;
|
||||
}
|
||||
|
||||
let prefix = mobileSelect.value;
|
||||
let middle = document.getElementById('newMobileMiddle')?.value.trim() || '';
|
||||
let last = document.getElementById('newMobileLast')?.value.trim() || '';
|
||||
|
||||
console.log('values:', {prefix, middle, last});
|
||||
|
||||
if (!prefix || !middle || !last) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 하이픈을 포함한 번호 조합
|
||||
let combinedNumber = `${prefix}-${middle}-${last}`;
|
||||
let mobileNumberInput = document.getElementById('mobileNumber');
|
||||
if (mobileNumberInput) {
|
||||
mobileNumberInput.value = combinedNumber;
|
||||
console.log('set combined number:', combinedNumber);
|
||||
}
|
||||
|
||||
return combinedNumber;
|
||||
}
|
||||
|
||||
// 전역 변수로 설정
|
||||
window.isAuthVerified = false;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
let countdownInterval;
|
||||
let isAuthVerified = false;
|
||||
|
||||
// 휴대폰 번호 파싱 함수 추가
|
||||
function parseMobileNumber(mobileNumber) {
|
||||
if (!mobileNumber) return null;
|
||||
|
||||
// "-" 포함하는 전화번호 형식 파싱
|
||||
const parts = mobileNumber.split('-');
|
||||
if (parts.length === 3) {
|
||||
return {
|
||||
prefix: parts[0],
|
||||
middle: parts[1],
|
||||
last: parts[2]
|
||||
};
|
||||
<div class="form-row" id="authNumberContainer" style="display: none;">
|
||||
<div class="form-label-wrapper">
|
||||
<span class="form-label-text">인증번호 입력</span> <span class="required-badge">필수</span>
|
||||
</div>
|
||||
<div class="form-field-wrapper input-with-button">
|
||||
<div class="auth-input-group">
|
||||
<input type="text" id="authNumber" name="authNumber" maxlength="6" pattern="[0-9]*"
|
||||
inputmode="numeric" class="form-input" placeholder="SMS 인증번호 6자리">
|
||||
<span class="auth-timer" id="certify_time">03:00</span>
|
||||
</div>
|
||||
<button type="button" class="btn-input-action btn-auth btn_verify_auth">
|
||||
인증번호 확인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="authNumber-validation"></div>
|
||||
</th:block>
|
||||
<th:block th:fragment="commonUserInfoScript">
|
||||
<script>
|
||||
// 휴대폰 번호 조합 함수
|
||||
function combineMobileNumber() {
|
||||
let mobileSelect = document.getElementById('phoneMobile');
|
||||
if (!mobileSelect) {
|
||||
console.error('Mobile select element not found');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 하이픈이 없는 경우
|
||||
if (mobileNumber.length === 11) {
|
||||
return {
|
||||
prefix: mobileNumber.substring(0, 3),
|
||||
middle: mobileNumber.substring(3, 7),
|
||||
last: mobileNumber.substring(7)
|
||||
};
|
||||
let prefix = mobileSelect.value;
|
||||
let middle = document.getElementById('newMobileMiddle')?.value.trim() || '';
|
||||
let last = document.getElementById('newMobileLast')?.value.trim() || '';
|
||||
|
||||
console.log('values:', { prefix, middle, last });
|
||||
|
||||
if (!prefix || !middle || !last) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
// 하이픈을 포함한 번호 조합
|
||||
let combinedNumber = `${prefix}-${middle}-${last}`;
|
||||
let mobileNumberInput = document.getElementById('mobileNumber');
|
||||
if (mobileNumberInput) {
|
||||
mobileNumberInput.value = combinedNumber;
|
||||
console.log('set combined number:', combinedNumber);
|
||||
}
|
||||
|
||||
return combinedNumber;
|
||||
}
|
||||
|
||||
// 초기 휴대폰 번호 설정
|
||||
function initializeMobileNumber() {
|
||||
const mobileNumber = document.querySelector('[name="mobileNumber"]')?.value;
|
||||
if (mobileNumber) {
|
||||
const parsed = parseMobileNumber(mobileNumber);
|
||||
if (parsed) {
|
||||
// 기존 번호 입력 필드 설정
|
||||
document.querySelector('[name="mobilePrefix"]').value = parsed.prefix;
|
||||
document.querySelector('[name="mobileMiddle"]').value = parsed.middle;
|
||||
document.querySelector('[name="mobileLast"]').value = parsed.last;
|
||||
// 전역 변수로 설정
|
||||
window.isAuthVerified = false;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
let countdownInterval;
|
||||
let isAuthVerified = false;
|
||||
|
||||
// 휴대폰 번호 파싱 함수 추가
|
||||
function parseMobileNumber(mobileNumber) {
|
||||
if (!mobileNumber) return null;
|
||||
|
||||
// "-" 포함하는 전화번호 형식 파싱
|
||||
const parts = mobileNumber.split('-');
|
||||
if (parts.length === 3) {
|
||||
return {
|
||||
prefix: parts[0],
|
||||
middle: parts[1],
|
||||
last: parts[2]
|
||||
};
|
||||
}
|
||||
|
||||
// 하이픈이 없는 경우
|
||||
if (mobileNumber.length === 11) {
|
||||
return {
|
||||
prefix: mobileNumber.substring(0, 3),
|
||||
middle: mobileNumber.substring(3, 7),
|
||||
last: mobileNumber.substring(7)
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// 초기 휴대폰 번호 설정
|
||||
function initializeMobileNumber() {
|
||||
const mobileNumber = document.querySelector('[name="mobileNumber"]')?.value;
|
||||
if (mobileNumber) {
|
||||
const parsed = parseMobileNumber(mobileNumber);
|
||||
if (parsed) {
|
||||
// 기존 번호 입력 필드 설정
|
||||
document.querySelector('[name="mobilePrefix"]').value = parsed.prefix;
|
||||
document.querySelector('[name="mobileMiddle"]').value = parsed.middle;
|
||||
document.querySelector('[name="mobileLast"]').value = parsed.last;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 전화번호 입력 필드 숫자만 입력 가능
|
||||
$('#newMobileMiddle, #newMobileLast, #authNumber').on('input', function () {
|
||||
$(this).val($(this).val().replace(/[^0-9]/g, ''));
|
||||
});
|
||||
// 전화번호 입력 필드 숫자만 입력 가능
|
||||
$('#newMobileMiddle, #newMobileLast, #authNumber').on('input', function () {
|
||||
$(this).val($(this).val().replace(/[^0-9]/g, ''));
|
||||
});
|
||||
|
||||
// select 변경 이벤트
|
||||
$('#phoneMobile').on('change', function () {
|
||||
combineMobileNumber();
|
||||
});
|
||||
// select 변경 이벤트
|
||||
$('#phoneMobile').on('change', function () {
|
||||
combineMobileNumber();
|
||||
});
|
||||
|
||||
// 인증번호 검증 결과 처리 함수
|
||||
function handleAuthNumberVerification(response) {
|
||||
customPopups.showAlert(response.message);
|
||||
if (response.valid) {
|
||||
let authNumberInput = document.getElementById('authNumber');
|
||||
let verifyAuthButton = document.querySelector(".btn_verify_auth");
|
||||
let timerElement = document.getElementById('certify_time');
|
||||
// 인증번호 검증 결과 처리 함수
|
||||
function handleAuthNumberVerification(response) {
|
||||
customPopups.showAlert(response.message);
|
||||
if (response.valid) {
|
||||
let authNumberInput = document.getElementById('authNumber');
|
||||
let verifyAuthButton = document.querySelector(".btn_verify_auth");
|
||||
let timerElement = document.getElementById('certify_time');
|
||||
|
||||
if (authNumberInput) authNumberInput.disabled = true;
|
||||
if (verifyAuthButton) verifyAuthButton.disabled = true;
|
||||
clearInterval(countdownInterval);
|
||||
if (timerElement) timerElement.textContent = '인증 완료';
|
||||
window.isAuthVerified = true; // 전역 변수로 설정
|
||||
document.dispatchEvent(new Event('authVerified'));
|
||||
if (authNumberInput) authNumberInput.disabled = true;
|
||||
if (verifyAuthButton) verifyAuthButton.disabled = true;
|
||||
clearInterval(countdownInterval);
|
||||
if (timerElement) timerElement.textContent = '인증 완료';
|
||||
window.isAuthVerified = true; // 전역 변수로 설정
|
||||
document.dispatchEvent(new Event('authVerified'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 테스트 환경 안내 메시지 표시
|
||||
function displayTestAuthNotice(authNumber) {
|
||||
const noticeHtml = `
|
||||
// 테스트 환경 안내 메시지 표시
|
||||
function displayTestAuthNotice(authNumber) {
|
||||
const noticeHtml = `
|
||||
<div class="test-env-notice" style="margin-top: 12px; padding: 12px 16px; background-color: #FFF4E6; border: 1px solid #FFB84D; border-radius: 4px;">
|
||||
<div class="test-env-notice__content" style="display: flex; align-items: flex-start; gap: 8px;">
|
||||
<svg class="test-env-notice__icon" style="flex-shrink: 0; margin-top: 2px; width: 20px; height: 20px;" viewBox="0 0 20 20" fill="none">
|
||||
@@ -230,184 +218,185 @@
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 기존 안내 메시지 제거
|
||||
$('.test-env-notice').remove();
|
||||
// 기존 안내 메시지 제거
|
||||
$('.test-env-notice').remove();
|
||||
|
||||
// 인증번호 container의 이전 위치에 안내 메시지 삽입
|
||||
const authContainer = document.getElementById('authNumberContainer');
|
||||
if (authContainer) {
|
||||
const noticeDiv = document.createElement('div');
|
||||
noticeDiv.className = 'form-row';
|
||||
noticeDiv.innerHTML = `<div class="form-label-wrapper label-offset"></div><div class="form-field-wrapper">${noticeHtml}</div>`;
|
||||
authContainer.parentNode.insertBefore(noticeDiv, authContainer);
|
||||
}
|
||||
}
|
||||
|
||||
// 인증번호 요청 후 처리 함수
|
||||
function handleAuthNumberRequest(response) {
|
||||
if (response.valid) {
|
||||
// 테스트 환경 인증번호 표시
|
||||
if (response.authNumber) {
|
||||
displayTestAuthNotice(response.authNumber);
|
||||
// 인증번호 container의 이전 위치에 안내 메시지 삽입
|
||||
const authContainer = document.getElementById('authNumberContainer');
|
||||
if (authContainer) {
|
||||
const noticeDiv = document.createElement('div');
|
||||
noticeDiv.className = 'form-row';
|
||||
noticeDiv.innerHTML = `<div class="form-label-wrapper label-offset"></div><div class="form-field-wrapper">${noticeHtml}</div>`;
|
||||
authContainer.parentNode.insertBefore(noticeDiv, authContainer);
|
||||
}
|
||||
|
||||
let authButton = document.querySelector(".btn_auth");
|
||||
|
||||
if (authButton) authButton.disabled = true;
|
||||
startAuthNumberTimer();
|
||||
customPopups.showAlert(response.message || "인증번호가 발송되었습니다.");
|
||||
} else {
|
||||
customPopups.showAlert(response.message || '알 수 없는 오류가 발생했습니다.');
|
||||
let authButton = document.querySelector(".btn_auth");
|
||||
if (authButton) authButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 인증번호 타이머 시작 함수
|
||||
function startAuthNumberTimer() {
|
||||
clearInterval(countdownInterval);
|
||||
let remainingTime = 180; // 3분
|
||||
let timerElement = document.getElementById("certify_time");
|
||||
let authButton = document.querySelector(".btn_auth");
|
||||
let verifyAuthButton = document.querySelector(".btn_verify_auth");
|
||||
let authNumberInput = document.getElementById('authNumber');
|
||||
// 인증번호 요청 후 처리 함수
|
||||
function handleAuthNumberRequest(response) {
|
||||
if (response.valid) {
|
||||
// 테스트 환경 인증번호 표시
|
||||
if (response.authNumber) {
|
||||
displayTestAuthNotice(response.authNumber);
|
||||
}
|
||||
|
||||
function updateRemainingTime() {
|
||||
if (remainingTime <= 0) {
|
||||
clearInterval(countdownInterval);
|
||||
if (timerElement) timerElement.textContent = "입력 시간이 초과되었습니다.";
|
||||
customPopups.showAlert("입력 시간이 초과되었습니다. 인증번호를 다시 발송해주세요.");
|
||||
if (authButton) authButton.disabled = false;
|
||||
if (verifyAuthButton) verifyAuthButton.disabled = true;
|
||||
if (authNumberInput) authNumberInput.readOnly = true;
|
||||
let authButton = document.querySelector(".btn_auth");
|
||||
|
||||
if (authButton) authButton.disabled = true;
|
||||
startAuthNumberTimer();
|
||||
customPopups.showAlert(response.message || "인증번호가 발송되었습니다.");
|
||||
} else {
|
||||
let minutes = Math.floor(remainingTime / 60);
|
||||
let seconds = remainingTime % 60;
|
||||
if (timerElement) timerElement.textContent = `남은 시간: ${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
remainingTime--;
|
||||
customPopups.showAlert(response.message || '알 수 없는 오류가 발생했습니다.');
|
||||
let authButton = document.querySelector(".btn_auth");
|
||||
if (authButton) authButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
updateRemainingTime();
|
||||
countdownInterval = setInterval(updateRemainingTime, 1000);
|
||||
}
|
||||
// 인증번호 타이머 시작 함수
|
||||
function startAuthNumberTimer() {
|
||||
clearInterval(countdownInterval);
|
||||
let remainingTime = 180; // 3분
|
||||
let timerElement = document.getElementById("certify_time");
|
||||
let authButton = document.querySelector(".btn_auth");
|
||||
let verifyAuthButton = document.querySelector(".btn_verify_auth");
|
||||
let authNumberInput = document.getElementById('authNumber');
|
||||
|
||||
// 이벤트 리스너 등록
|
||||
document.querySelectorAll('#newMobileMiddle, #newMobileLast')
|
||||
.forEach(el => el.addEventListener('input', combineMobileNumber));
|
||||
|
||||
let authNumberInput = document.getElementById("authNumber");
|
||||
if (authNumberInput) {
|
||||
authNumberInput.addEventListener("input", function () {
|
||||
this.value = this.value.replace(/\D/g, '');
|
||||
this.classList.toggle('is-invalid', this.value.length !== 6);
|
||||
});
|
||||
}
|
||||
|
||||
let userNameInput = document.getElementById("userName");
|
||||
if (userNameInput) {
|
||||
userNameInput.addEventListener("input", function () {
|
||||
this.classList.toggle('is-invalid', this.value.trim().length === 0);
|
||||
});
|
||||
}
|
||||
|
||||
// 인증번호 요청 버튼 이벤트 리스너
|
||||
let authButton = document.querySelector(".btn_auth");
|
||||
if (authButton) {
|
||||
authButton.addEventListener("click", function () {
|
||||
// 먼저 새로운 휴대폰 번호 조합
|
||||
let newMobileNumber = combineMobileNumber();
|
||||
if (!newMobileNumber) {
|
||||
customPopups.showAlert("휴대폰 번호를 올바르게 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 기존 휴대폰 번호 가져오기 (하이픈 없이)
|
||||
const originalPrefix = document.querySelector('[name="mobilePrefix"]').value;
|
||||
const originalMiddle = document.querySelector('[name="mobileMiddle"]').value;
|
||||
const originalLast = document.querySelector('[name="mobileLast"]').value;
|
||||
const originalMobileNumber = `${originalPrefix}${originalMiddle}${originalLast}`;
|
||||
|
||||
// 새 번호에서도 하이픈 제거하여 비교
|
||||
const newMobileRaw = newMobileNumber.replace(/-/g, '');
|
||||
|
||||
// 동일한 번호인지 체크
|
||||
if (newMobileRaw === originalMobileNumber) {
|
||||
customPopups.showAlert("동일한 휴대폰 번호입니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Original Mobile:', originalMobileNumber);
|
||||
console.log('New Mobile:', newMobileNumber);
|
||||
|
||||
// jQuery AJAX 호출
|
||||
$.ajax({
|
||||
url: /*[[@{/auth/request_auth_number}]]*/ '/auth/request_auth_number',
|
||||
type: 'POST',
|
||||
data: {
|
||||
mobileNumber: newMobileNumber,
|
||||
_csrf: $('input[name="_csrf"]').val()
|
||||
},
|
||||
success: function(response) {
|
||||
handleAuthNumberRequest(response);
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
customPopups.showAlert("처리 중 오류가 발생했습니다. 다시 시도해주세요.");
|
||||
function updateRemainingTime() {
|
||||
if (remainingTime <= 0) {
|
||||
clearInterval(countdownInterval);
|
||||
if (timerElement) timerElement.textContent = "입력 시간이 초과되었습니다.";
|
||||
customPopups.showAlert("입력 시간이 초과되었습니다. 인증번호를 다시 발송해주세요.");
|
||||
if (authButton) authButton.disabled = false;
|
||||
if (verifyAuthButton) verifyAuthButton.disabled = true;
|
||||
if (authNumberInput) authNumberInput.readOnly = true;
|
||||
} else {
|
||||
let minutes = Math.floor(remainingTime / 60);
|
||||
let seconds = remainingTime % 60;
|
||||
if (timerElement) timerElement.textContent = `남은 시간: ${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
remainingTime--;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 인증번호 확인 버튼 이벤트 리스너
|
||||
let verifyAuthButton = document.querySelector(".btn_verify_auth");
|
||||
if (verifyAuthButton) {
|
||||
verifyAuthButton.addEventListener("click", function () {
|
||||
let authNumber = document.getElementById('authNumber').value;
|
||||
if (authNumber.length !== 6) {
|
||||
customPopups.showAlert("6자리 인증번호를 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
let mobileNumber = document.getElementById('mobileNumber').value;
|
||||
updateRemainingTime();
|
||||
countdownInterval = setInterval(updateRemainingTime, 1000);
|
||||
}
|
||||
|
||||
// jQuery AJAX 호출
|
||||
$.ajax({
|
||||
url: /*[[@{/auth/verify_auth_number}]]*/ '/auth/verify_auth_number',
|
||||
type: 'POST',
|
||||
data: {
|
||||
mobileNumber: mobileNumber,
|
||||
authNumber: authNumber,
|
||||
_csrf: $('input[name="_csrf"]').val()
|
||||
},
|
||||
success: function(response) {
|
||||
handleAuthNumberVerification(response);
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
customPopups.showAlert("처리 중 오류가 발생했습니다. 다시 시도해주세요.");
|
||||
}
|
||||
// 이벤트 리스너 등록
|
||||
document.querySelectorAll('#newMobileMiddle, #newMobileLast')
|
||||
.forEach(el => el.addEventListener('input', combineMobileNumber));
|
||||
|
||||
let authNumberInput = document.getElementById("authNumber");
|
||||
if (authNumberInput) {
|
||||
authNumberInput.addEventListener("input", function () {
|
||||
this.value = this.value.replace(/\D/g, '');
|
||||
this.classList.toggle('is-invalid', this.value.length !== 6);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 휴대폰 번호 변경 버튼 이벤트 리스너
|
||||
const changeMobilePhoneBtn = document.querySelector('.change_mobile_phone');
|
||||
const newPhoneNumberContainer = document.getElementById('newPhoneNumberContainer');
|
||||
const authNumberContainer = document.getElementById('authNumberContainer');
|
||||
let userNameInput = document.getElementById("userName");
|
||||
if (userNameInput) {
|
||||
userNameInput.addEventListener("input", function () {
|
||||
this.classList.toggle('is-invalid', this.value.trim().length === 0);
|
||||
});
|
||||
}
|
||||
|
||||
if (changeMobilePhoneBtn && newPhoneNumberContainer && authNumberContainer) {
|
||||
changeMobilePhoneBtn.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
// 인증번호 요청 버튼 이벤트 리스너
|
||||
let authButton = document.querySelector(".btn_auth");
|
||||
if (authButton) {
|
||||
authButton.addEventListener("click", function () {
|
||||
// 먼저 새로운 휴대폰 번호 조합
|
||||
let newMobileNumber = combineMobileNumber();
|
||||
if (!newMobileNumber) {
|
||||
customPopups.showAlert("휴대폰 번호를 올바르게 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
newPhoneNumberContainer.style.display = '';
|
||||
authNumberContainer.style.display = '';
|
||||
changeMobilePhoneBtn.style.display = 'none';
|
||||
});
|
||||
}
|
||||
// 기존 휴대폰 번호 가져오기 (하이픈 없이)
|
||||
const originalPrefix = document.querySelector('[name="mobilePrefix"]').value;
|
||||
const originalMiddle = document.querySelector('[name="mobileMiddle"]').value;
|
||||
const originalLast = document.querySelector('[name="mobileLast"]').value;
|
||||
const originalMobileNumber = `${originalPrefix}${originalMiddle}${originalLast}`;
|
||||
|
||||
// 초기화 실행
|
||||
initializeMobileNumber();
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
// 새 번호에서도 하이픈 제거하여 비교
|
||||
const newMobileRaw = newMobileNumber.replace(/-/g, '');
|
||||
|
||||
// 동일한 번호인지 체크
|
||||
if (newMobileRaw === originalMobileNumber) {
|
||||
customPopups.showAlert("동일한 휴대폰 번호입니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Original Mobile:', originalMobileNumber);
|
||||
console.log('New Mobile:', newMobileNumber);
|
||||
|
||||
// jQuery AJAX 호출
|
||||
$.ajax({
|
||||
url: /*[[@{/auth/request_auth_number}]]*/ '/auth/request_auth_number',
|
||||
type: 'POST',
|
||||
data: {
|
||||
mobileNumber: newMobileNumber,
|
||||
_csrf: $('input[name="_csrf"]').val()
|
||||
},
|
||||
success: function (response) {
|
||||
handleAuthNumberRequest(response);
|
||||
},
|
||||
error: function (xhr, status, error) {
|
||||
customPopups.showAlert("처리 중 오류가 발생했습니다. 다시 시도해주세요.");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 인증번호 확인 버튼 이벤트 리스너
|
||||
let verifyAuthButton = document.querySelector(".btn_verify_auth");
|
||||
if (verifyAuthButton) {
|
||||
verifyAuthButton.addEventListener("click", function () {
|
||||
let authNumber = document.getElementById('authNumber').value;
|
||||
if (authNumber.length !== 6) {
|
||||
customPopups.showAlert("6자리 인증번호를 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
let mobileNumber = document.getElementById('mobileNumber').value;
|
||||
|
||||
// jQuery AJAX 호출
|
||||
$.ajax({
|
||||
url: /*[[@{/auth/verify_auth_number}]]*/ '/auth/verify_auth_number',
|
||||
type: 'POST',
|
||||
data: {
|
||||
mobileNumber: mobileNumber,
|
||||
authNumber: authNumber,
|
||||
_csrf: $('input[name="_csrf"]').val()
|
||||
},
|
||||
success: function (response) {
|
||||
handleAuthNumberVerification(response);
|
||||
},
|
||||
error: function (xhr, status, error) {
|
||||
customPopups.showAlert("처리 중 오류가 발생했습니다. 다시 시도해주세요.");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 휴대폰 번호 변경 버튼 이벤트 리스너
|
||||
const changeMobilePhoneBtn = document.querySelector('.change_mobile_phone');
|
||||
const newPhoneNumberContainer = document.getElementById('newPhoneNumberContainer');
|
||||
const authNumberContainer = document.getElementById('authNumberContainer');
|
||||
|
||||
if (changeMobilePhoneBtn && newPhoneNumberContainer && authNumberContainer) {
|
||||
changeMobilePhoneBtn.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
newPhoneNumberContainer.style.display = '';
|
||||
authNumberContainer.style.display = '';
|
||||
changeMobilePhoneBtn.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// 초기화 실행
|
||||
initializeMobileNumber();
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
@@ -25,7 +25,7 @@
|
||||
<div class="detail-wrap">
|
||||
<!-- Title Bar -->
|
||||
<div class="board-header">
|
||||
<h2 class="board-title">인증키 정보</h2>
|
||||
<h2 class="board-title">클라이언트 정보</h2>
|
||||
<span class="board-desc">등록된 앱의 상세 정보를 확인할 수 있습니다.</span>
|
||||
</div>
|
||||
|
||||
@@ -188,11 +188,11 @@
|
||||
|
||||
<!-- Bottom Navigation Actions -->
|
||||
<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"
|
||||
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"
|
||||
th:href="@{/myapikey/modify/step1(clientId=${apiKey.clientid})}">수정</a>
|
||||
th:href="@{/clients/modify/step1(clientId=${apiKey.clientid})}">변경 신청</a>
|
||||
</div>
|
||||
|
||||
</div><!-- /detail-wrap -->
|
||||
@@ -218,43 +218,48 @@
|
||||
);
|
||||
}
|
||||
|
||||
// Show password prompt for viewing client secret (최초 1회 노출 + 서버측 물리 삭제)
|
||||
// Client Secret 조회 진입 — 확인 후 조회 (본인 확인은 step-up 2FA)
|
||||
function showPasswordPrompt() {
|
||||
customPopups.showPasswordInput({
|
||||
title: 'Client Secret 조회',
|
||||
message: '보안을 위해 비밀번호를 입력해주세요.<br>조회 즉시 값은 영구 삭제됩니다.',
|
||||
onConfirm: function (password) {
|
||||
$.ajax({
|
||||
url: /*[[@{/myapikey/credential/reveal-secret}]]*/ '/myapikey/credential/reveal-secret',
|
||||
type: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({ clientId: CREDENTIAL_CLIENT_ID, password: password }),
|
||||
headers: {
|
||||
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
|
||||
}
|
||||
}).done(function (response) {
|
||||
if (response.success) {
|
||||
customPopups.hidePasswordInput();
|
||||
|
||||
// 서버가 반환한 secret을 화면에 1회 주입
|
||||
$('#revealedSecretValue').text(response.secret);
|
||||
$('#revealedSecretCopyBtn').attr('data-secret', response.secret).data('secret', response.secret);
|
||||
|
||||
$('#hiddenSecretBox').hide();
|
||||
$('#revealedSecretBox').fadeIn(300);
|
||||
} else if (response.alreadyRevealed) {
|
||||
customPopups.hidePasswordInput();
|
||||
showLostKeyGuide();
|
||||
} else {
|
||||
customPopups.showPasswordError(response.message || '비밀번호가 일치하지 않습니다.');
|
||||
}
|
||||
}).fail(function () {
|
||||
customPopups.showPasswordError('오류가 발생했습니다. 다시 시도해주세요.');
|
||||
});
|
||||
},
|
||||
onCancel: function () {
|
||||
// User cancelled - do nothing
|
||||
customPopups.showConfirm(
|
||||
'Client Secret은 <strong>지금 한 번만</strong> 조회할 수 있으며,<br>조회 즉시 값은 <strong>영구 삭제</strong>됩니다.<br><br>조회하시겠습니까?',
|
||||
function (confirmed) {
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
doRevealSecret();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Secret 조회 요청 (step-up 필요 시 2FA 후 동일 요청 재시도)
|
||||
function doRevealSecret() {
|
||||
$.ajax({
|
||||
url: /*[[@{/clients/credential/reveal-secret}]]*/ '/clients/credential/reveal-secret',
|
||||
type: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({ clientId: CREDENTIAL_CLIENT_ID }),
|
||||
headers: {
|
||||
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
|
||||
}
|
||||
}).done(function (response) {
|
||||
if (response.success) {
|
||||
// 서버가 반환한 secret을 화면에 1회 주입
|
||||
$('#revealedSecretValue').text(response.secret);
|
||||
$('#revealedSecretCopyBtn').attr('data-secret', response.secret).data('secret', response.secret);
|
||||
|
||||
$('#hiddenSecretBox').hide();
|
||||
$('#revealedSecretBox').fadeIn(300);
|
||||
} else if (response.alreadyRevealed) {
|
||||
showLostKeyGuide();
|
||||
} else {
|
||||
customPopups.showAlert(response.message || 'Client Secret 조회에 실패했습니다.');
|
||||
}
|
||||
}).fail(function (jqXHR) {
|
||||
if (isStepUpRequired(jqXHR)) {
|
||||
requireStepUp('/clients/credential/reveal-secret', function () { doRevealSecret(); });
|
||||
return;
|
||||
}
|
||||
customPopups.showAlert('오류가 발생했습니다. 다시 시도해주세요.');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -307,7 +312,7 @@
|
||||
$('.loading-overlay').show();
|
||||
|
||||
$.ajax({
|
||||
url: /*[[@{/myapikey/api_key_delete}]]*/ '/myapikey/api_key_delete',
|
||||
url: /*[[@{/clients/api_key_delete}]]*/ '/clients/api_key_delete',
|
||||
type: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({ clientId: clientId }),
|
||||
@@ -320,11 +325,11 @@
|
||||
return;
|
||||
}
|
||||
customPopups.showAlert(response.msg || 'API Key가 삭제되었습니다.', function () {
|
||||
window.location.href = /*[[@{/myapikey}]]*/ '/myapikey';
|
||||
window.location.href = /*[[@{/clients}]]*/ '/clients';
|
||||
});
|
||||
}).fail(function (jqXHR, textStatus, errorThrown) {
|
||||
if (isStepUpRequired(jqXHR)) {
|
||||
requireStepUp('/myapikey/api_key_delete', function () { doDeleteApiKey(clientId); });
|
||||
requireStepUp('/clients/api_key_delete', function () { doDeleteApiKey(clientId); });
|
||||
return;
|
||||
}
|
||||
customPopups.showAlert('API 삭제 요청 중 오류가 발생했습니다: ' + errorThrown);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||
xmlns:th="http://www.thymeleaf.org"
|
||||
layout:decorate="~{layout/djbank_base_layout}">
|
||||
layout:decorate="~{layout/djbank_title_layout}">
|
||||
<body>
|
||||
<section layout:fragment="title">
|
||||
<div class="page-title-banner">
|
||||
@@ -10,59 +10,78 @@
|
||||
</div>
|
||||
</section>
|
||||
<section layout:fragment="contentFragment" class="content">
|
||||
<div class="org-register-page corporate-register">
|
||||
<div class="org-register-container">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
|
||||
<input type="hidden" name="registrationType" th:value="${registrationType}"/>
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
|
||||
<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:replace="apps/register/userAgreementContent :: agreementContent(${termsOfUse}, ${privacyCollect})"></th:block>
|
||||
<th:block th:replace="~{apps/register/userAgreementContent :: agreementContent(${termsOfUse}, ${privacyCollect})}"></th:block>
|
||||
|
||||
<!-- 법인 기본 정보 폼 -->
|
||||
<form name="portalOrg" id="businessTransferForm" method="post" th:action="@{/mypage/org-transfer}"
|
||||
th:object="${portalOrg}" enctype="multipart/form-data">
|
||||
|
||||
<div class="org-section-header">
|
||||
<h3>법인 기본 정보</h3>
|
||||
<span class="required-badge">필수 입력</span>
|
||||
</div>
|
||||
<div class="org-section-header org-section-header--agreement">
|
||||
<h3>법인 기본 정보</h3>
|
||||
<span class="required-badge">필수 입력</span>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<h3>법인 관리자 정보</h3>
|
||||
<span class="required-badge">필수 입력</span>
|
||||
</div>
|
||||
<!-- 법인 관리자 정보 -->
|
||||
<div class="org-section-header org-section-header--agreement" style="margin-top: 48px;">
|
||||
<h3>법인 관리자 정보</h3>
|
||||
<span class="required-badge">필수 입력</span>
|
||||
</div>
|
||||
|
||||
<div class="org-info-notice">
|
||||
<ul>
|
||||
<li>법인 관리자는 API 신청 및 개발자 초대 등 모든 권한을 보유합니다.</li>
|
||||
<li>법인회원 전환은 반드시 법인이메일 주소로 입력하시기 바랍니다. 개인이메일로 전환 신청시, 법인 승인이 거절 될 수 있습니다</li>
|
||||
</ul>
|
||||
<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 class="org-form-container">
|
||||
<div class="org-form-group">
|
||||
<label class="org-form-label">
|
||||
<span>이메일 아이디</span>
|
||||
</label>
|
||||
<div class="org-form-input-wrapper">
|
||||
<input type="text" th:value="${user.loginId}" name="loginId"
|
||||
class="org-form-input input-readonly"
|
||||
placeholder="이메일" disabled="disabled">
|
||||
<input type="hidden" th:value="${user.loginId}" name="loginId">
|
||||
</div>
|
||||
</div>
|
||||
<div class="org-form-container">
|
||||
<div class="org-form-group">
|
||||
<label class="org-form-label">
|
||||
<span>이메일 아이디</span>
|
||||
</label>
|
||||
<div class="org-form-input-wrapper">
|
||||
<input type="text" th:value="${user.loginId}" name="loginId"
|
||||
class="org-form-input input-readonly"
|
||||
placeholder="이메일" disabled="disabled">
|
||||
<input type="hidden" th:value="${user.loginId}" name="loginId">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="org-action-buttons">
|
||||
<button type="button" class="btn btn-secondary" id="cancelButton" th:onclick="|location.href='@{/mypage}'|">취소</button>
|
||||
<button type="button" class="btn btn-primary" id="submitButton">법인회원 전환</button>
|
||||
</div>
|
||||
<!-- Action Buttons -->
|
||||
<div class="org-action-buttons">
|
||||
<button type="button" class="btn btn-secondary" id="cancelButton" th:onclick="|location.href='@{/mypage}'|">취소</button>
|
||||
<button type="button" class="btn btn-primary" id="submitButton">법인회원 전환</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<th:block layout:fragment="contentScript">
|
||||
<script th:if="${error}" th:inline="javascript">
|
||||
|
||||
@@ -1,113 +1,172 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||
layout:decorate="~{layout/djbank_title_layout}">
|
||||
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||
layout:decorate="~{layout/djbank_title_layout}">
|
||||
|
||||
<head>
|
||||
<title>비밀번호 변경</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<section layout:fragment="title">
|
||||
<div class="page-title-banner">
|
||||
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||
<h1>비밀번호 변경</h1>
|
||||
</div>
|
||||
</section>
|
||||
<section layout:fragment="contentFragment">
|
||||
<div class="service-main app-management-layout">
|
||||
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('password')}"></th:block>
|
||||
<div class="app-management-content">
|
||||
<div class="password-change-wrapper">
|
||||
<h2 class="page-outer-title">비밀번호 변경</h2>
|
||||
<section layout:fragment="title">
|
||||
<div class="page-title-banner">
|
||||
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||
<h1>비밀번호 변경</h1>
|
||||
</div>
|
||||
</section>
|
||||
<section layout:fragment="contentFragment">
|
||||
<div class="service-main app-management-layout">
|
||||
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('password')}"></th:block>
|
||||
<div class="app-management-content">
|
||||
<div class="password-change-wrapper">
|
||||
<h2 class="page-outer-title">비밀번호 변경</h2>
|
||||
|
||||
<form id="passwordChangeForm" th:action="@{/password/change}" method="post">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
|
||||
<form id="passwordChangeForm" th:action="@{/password/change}" method="post">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
|
||||
<div class="register-form-container">
|
||||
<div class="info-notice-box">
|
||||
<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>소중한 계정 보호를 위해 비밀번호를 변경해 주세요!<br>
|
||||
비밀번호 변경이 완료되면 자동으로 로그아웃되며, 새 비밀번호로 다시 로그인해야 합니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="register-form-container">
|
||||
<div class="info-notice-box">
|
||||
<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 class="form-row">
|
||||
<div class="form-label-wrapper">
|
||||
<span class="form-label-text">새 비밀번호</span>
|
||||
</div>
|
||||
<div class="form-field-wrapper">
|
||||
<input type="password" id="newPassword" name="newPassword" class="form-input"
|
||||
placeholder="새 비밀번호 입력" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-label-wrapper">
|
||||
<span class="form-label-text">새 비밀번호 확인</span>
|
||||
</div>
|
||||
<div class="form-field-wrapper">
|
||||
<input type="password" name="confirmPassword" class="form-input"
|
||||
placeholder="새 비밀번호 확인 입력" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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="letter" 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
|
||||
class="policy-text">숫자 포함</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>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-label-wrapper">
|
||||
<span class="form-label-text">새 비밀번호</span>
|
||||
</div>
|
||||
<div class="form-field-wrapper">
|
||||
<input type="password" id="newPassword" name="newPassword" class="form-input"
|
||||
placeholder="새 비밀번호 입력" required>
|
||||
<div class="form-actions" style="justify-content: flex-end;">
|
||||
<div class="right-buttons">
|
||||
<button type="button" class="btn-cancel" onclick="history.back()">취소</button>
|
||||
<button type="submit" class="btn-apply btn-primary">변경</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-label-wrapper">
|
||||
<span class="form-label-text">새 비밀번호 확인</span>
|
||||
</div>
|
||||
<div class="form-field-wrapper">
|
||||
<input type="password" name="confirmPassword" class="form-input"
|
||||
placeholder="새 비밀번호 확인 입력" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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="letter" 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 class="policy-text">숫자 포함</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>
|
||||
</ul>
|
||||
<p class="password-policy-note">아이디, 휴대전화 번호는 비밀번호에 사용할 수 없습니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-actions" style="justify-content: flex-end;">
|
||||
<div class="right-buttons">
|
||||
<button type="button" class="btn-cancel" onclick="history.back()">취소</button>
|
||||
<button type="submit" class="btn-apply btn-primary">변경</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script th:if="${error}" th:inline="javascript">
|
||||
$(document).ready(function() {
|
||||
customPopups.showAlert([[${error}]]);
|
||||
})
|
||||
</script>
|
||||
<script th:inline="javascript">
|
||||
// 반영 직전 2FA: twofaRequired 면 제출을 가로채 2FA 팝업 → 성공 시 실제 제출.
|
||||
(function () {
|
||||
var twofaRequired = /*[[${twofaRequired}]]*/ false;
|
||||
if (!twofaRequired) return;
|
||||
var form = document.getElementById('passwordChangeForm');
|
||||
if (!form) return;
|
||||
<script th:if="${error}" th:inline="javascript">
|
||||
$(document).ready(function () {
|
||||
customPopups.showAlert([[${ error }]]);
|
||||
})
|
||||
</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;
|
||||
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
var np = document.getElementById('newPassword').value;
|
||||
var cp = form.querySelector('input[name="confirmPassword"]').value;
|
||||
if (!np || !cp) { customPopups.showAlert('새 비밀번호를 입력해 주세요.'); return; }
|
||||
if (np !== cp) { customPopups.showAlert('새 비밀번호가 일치하지 않습니다.'); return; }
|
||||
if (window.PasswordPolicy && !PasswordPolicy.isValid(np)) {
|
||||
customPopups.showAlert('비밀번호 규칙을 확인해 주세요.'); return;
|
||||
function setState(li, state) {
|
||||
li.classList.remove('is-idle', 'is-pass', 'is-fail');
|
||||
li.classList.add(state);
|
||||
}
|
||||
|
||||
if (typeof TwoFactorAuth === 'undefined') { form.submit(); return; }
|
||||
TwoFactorAuth.open({
|
||||
purpose: '/password/change',
|
||||
// form.submit() 은 submit 이벤트를 재발생시키지 않으므로 그대로 서버로 전송된다.
|
||||
onSuccess: function () { form.submit(); },
|
||||
onCancel: function () { /* 사용자 취소 — 유지 */ }
|
||||
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>
|
||||
</section>
|
||||
})();
|
||||
</script>
|
||||
<script th:inline="javascript">
|
||||
// 반영 직전 2FA: twofaRequired 면 제출을 가로채 2FA 팝업 → 성공 시 실제 제출.
|
||||
(function () {
|
||||
var twofaRequired = /*[[${twofaRequired}]]*/ false;
|
||||
if (!twofaRequired) return;
|
||||
var form = document.getElementById('passwordChangeForm');
|
||||
if (!form) return;
|
||||
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
var np = document.getElementById('newPassword').value;
|
||||
var cp = form.querySelector('input[name="confirmPassword"]').value;
|
||||
if (!np || !cp) { customPopups.showAlert('새 비밀번호를 입력해 주세요.'); return; }
|
||||
if (np !== cp) { customPopups.showAlert('새 비밀번호가 일치하지 않습니다.'); return; }
|
||||
if (window.PasswordPolicy && !PasswordPolicy.isValid(np)) {
|
||||
customPopups.showAlert('비밀번호 규칙을 확인해 주세요.'); return;
|
||||
}
|
||||
|
||||
if (typeof TwoFactorAuth === 'undefined') { form.submit(); return; }
|
||||
TwoFactorAuth.open({
|
||||
purpose: '/password/change',
|
||||
// form.submit() 은 submit 이벤트를 재발생시키지 않으므로 그대로 서버로 전송된다.
|
||||
onSuccess: function () { form.submit(); },
|
||||
onCancel: function () { /* 사용자 취소 — 유지 */ }
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
@@ -402,16 +402,17 @@
|
||||
const last = document.getElementById('newMobileLast')?.value.trim();
|
||||
|
||||
if (prefix === '선택' || !middle || !last) return null;
|
||||
return prefix + middle + last;
|
||||
// 저장 표준(하이픈 정규형)에 맞춰 조합
|
||||
return `${prefix}-${middle}-${last}`;
|
||||
},
|
||||
|
||||
// 기존 휴대폰 번호 가져오기 (하이픈 없이)
|
||||
// 기존 휴대폰 번호 가져오기 (하이픈 정규형)
|
||||
getExistingMobileNumber: () => {
|
||||
const prefix = document.querySelector('[name="mobilePrefix"]')?.value;
|
||||
const middle = document.querySelector('[name="mobileMiddle"]')?.value;
|
||||
const last = document.querySelector('[name="mobileLast"]')?.value;
|
||||
|
||||
return prefix + middle + last;
|
||||
return `${prefix}-${middle}-${last}`;
|
||||
},
|
||||
|
||||
// 휴대폰 번호 변경 여부 확인
|
||||
|
||||
@@ -10,33 +10,34 @@
|
||||
</section>
|
||||
<section layout:fragment="contentFragment">
|
||||
<div class="org-register-container">
|
||||
|
||||
<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}">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
|
||||
<div class="register-form">
|
||||
<div class="form-row">
|
||||
<div class="form-label-wrapper label-offset">
|
||||
<span class="form-label-text">소속기관</span>
|
||||
</div>
|
||||
<div class="form-field-wrapper">
|
||||
<input type="text" th:value="${user.portalOrg.orgName}" name="organization"
|
||||
class="form-input input-readonly info-value-box" placeholder="소속기관" disabled="disabled">
|
||||
<div class="corp-manager-wrapper">
|
||||
<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}">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
|
||||
<div class="register-form">
|
||||
<div class="form-row">
|
||||
<div class="form-label-wrapper">
|
||||
<span class="form-label-text">소속기관</span>
|
||||
</div>
|
||||
<div class="form-field-wrapper">
|
||||
<input type="text" th:value="${user.portalOrg.orgName}" name="organization"
|
||||
class="form-input input-readonly info-value-box" placeholder="소속기관" disabled="disabled">
|
||||
</div>
|
||||
</div>
|
||||
<th:block th:replace="~{apps/mypage/components/commonUserInfo :: commonUserInfo}"></th:block>
|
||||
</div>
|
||||
<th:block th:replace="~{apps/mypage/components/commonUserInfo :: commonUserInfo}"></th:block>
|
||||
</form>
|
||||
</div>
|
||||
<!-- Action Buttons -->
|
||||
<div class="form-actions form-actions--with-withdrawal">
|
||||
<a class="withdrawal-link"><img th:src="@{/img/btn_withdrawal.png}" alt="회원탈퇴">회원탈퇴</a>
|
||||
<div class="form-actions-buttons">
|
||||
<button type="button" class="btn btn-submit btn-secondary" th:onclick="|location.href='@{/}'|">취소</button>
|
||||
<button type="button" class="btn btn-submit btn-primary submit-btn">수정 적용</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<!-- Action Buttons -->
|
||||
<div class="form-actions form-actions--with-withdrawal">
|
||||
<a class="withdrawal-link"><img th:src="@{/img/btn_withdrawal.png}" alt="회원탈퇴">회원탈퇴</a>
|
||||
<div class="form-actions-buttons">
|
||||
<button type="button" class="btn btn-submit btn-secondary" th:onclick="|location.href='@{/}'|">취소</button>
|
||||
<button type="button" class="btn btn-submit btn-primary submit-btn">수정 적용</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -76,7 +77,8 @@
|
||||
const originalPrefix = document.querySelector('input[name="mobilePrefix"]').value;
|
||||
const originalMiddle = document.querySelector('input[name="mobileMiddle"]').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();
|
||||
@@ -102,7 +104,7 @@
|
||||
|
||||
// 하이픈 제거 후 비교
|
||||
const newMobileRaw = newMobileNumber.replace(/-/g, '');
|
||||
if (newMobileRaw !== originalMobileNumber) {
|
||||
if (newMobileRaw !== originalMobileNumber.replace(/-/g, '')) {
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,198 +1,220 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||
layout:decorate="~{layout/djbank_title_layout}">
|
||||
<html xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
|
||||
|
||||
<body>
|
||||
<section layout:fragment="title">
|
||||
<div class="page-title-banner">
|
||||
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||
<h1>내 정보 관리</h1>
|
||||
</div>
|
||||
</section>
|
||||
<section layout:fragment="contentFragment">
|
||||
<div class="org-register-container">
|
||||
|
||||
<!-- 초대 알림 배너 -->
|
||||
<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>
|
||||
<strong style="color: #1976D2;">법인 회원 초대가 대기 중입니다</strong>
|
||||
<p style="margin: 5px 0 0 0; color: #666;">기관에서 귀하를 법인 회원으로 초대했습니다. 초대를 수락하면 해당 기관의 법인 회원으로 전환됩니다.</p>
|
||||
</div>
|
||||
<div>
|
||||
<a th:href="@{/signup/decision(invitation=${invitationToken})}" class="common_btn_type_1 blue" style="white-space: nowrap;">
|
||||
<span>초대 확인</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<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}">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
|
||||
<div class="register-form">
|
||||
<th:block th:replace="~{apps/mypage/components/commonUserInfo :: commonUserInfo}"></th:block>
|
||||
<section layout:fragment="title">
|
||||
<div class="page-title-banner">
|
||||
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||
<h1>내 정보 관리</h1>
|
||||
</div>
|
||||
</section>
|
||||
<section layout:fragment="contentFragment">
|
||||
<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 style="display: flex; align-items: center; justify-content: space-between;">
|
||||
<div>
|
||||
<strong style="color: #1976D2;">법인 회원 초대가 대기 중입니다</strong>
|
||||
<p style="margin: 5px 0 0 0; color: #666;">기관에서 귀하를 법인 회원으로 초대했습니다. 초대를 수락하면 해당 기관의 법인
|
||||
회원으로 전환됩니다.</p>
|
||||
</div>
|
||||
<div>
|
||||
<a th:href="@{/signup/decision(invitation=${invitationToken})}"
|
||||
class="common_btn_type_1 blue" style="white-space: nowrap;">
|
||||
<span>초대 확인</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Corporate Transfer Button -->
|
||||
<div class="corporate-transfer-section">
|
||||
<button type="button" class="btn btn-secondary btn_business btn-block">
|
||||
법인회원 전환
|
||||
</button>
|
||||
<h2 class="page-outer-title">정보 관리</h2>
|
||||
|
||||
<form id="updateForm" method="post" th:action="@{/mypage/update}" th:object="${user}">
|
||||
<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">
|
||||
<th:block th:replace="~{apps/mypage/components/commonUserInfo :: commonUserInfo}">
|
||||
</th:block>
|
||||
</div>
|
||||
|
||||
<!-- Corporate Transfer Button -->
|
||||
<div class="corporate-transfer-section" style="margin-top: 24px;">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<input type="hidden" name="finalMobileNumber" />
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="form-actions">
|
||||
<a class="withdrawal-link btn-withdrawal">회원탈퇴</a>
|
||||
<div class="right-buttons">
|
||||
<button type="button" class="btn-cancel" th:onclick="|location.href='@{/}'|">취소</button>
|
||||
<button type="button" class="btn-apply btn-primary submit-btn">수정</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<input type="hidden" name="finalMobileNumber"/>
|
||||
</section>
|
||||
<th:block layout:fragment="contentScript">
|
||||
<script th:if="${error}" th:inline="javascript">
|
||||
$(document).ready(function () {
|
||||
customPopups.showAlert([[${ error }]]);
|
||||
});
|
||||
</script>
|
||||
<script th:inline="javascript">
|
||||
$(document).ready(function () {
|
||||
// 기존 success 메시지 처리
|
||||
var successMsg = [[${ success }]];
|
||||
console.log("Success message:", successMsg);
|
||||
if (successMsg) {
|
||||
console.log("Showing alert");
|
||||
customPopups.showAlert(successMsg);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script th:replace="~{apps/mypage/components/commonUserInfo :: commonUserInfoScript}"></script>
|
||||
<script th:inline="javascript">
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const form = document.getElementById('updateForm');
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="form-actions form-actions--with-withdrawal">
|
||||
<a class="withdrawal-link"><img th:src="@{/img/btn_withdrawal.png}" alt="회원탈퇴">회원탈퇴</a>
|
||||
<div class="form-actions-buttons">
|
||||
<button type="button" class="btn btn-submit btn-secondary" th:onclick="|location.href='@{/}'|">취소</button>
|
||||
<button type="button" class="btn btn-submit btn-primary submit-btn">수정</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
// 수정 버튼 이벤트
|
||||
const submitButton = document.querySelector('.btn-primary.submit-btn');
|
||||
if (submitButton) {
|
||||
submitButton.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
</section>
|
||||
<th:block layout:fragment="contentScript">
|
||||
<script th:if="${error}" th:inline="javascript">
|
||||
$(document).ready(function () {
|
||||
customPopups.showAlert([[${error}]]);
|
||||
});
|
||||
</script>
|
||||
<script th:inline="javascript">
|
||||
$(document).ready(function() {
|
||||
// 기존 success 메시지 처리
|
||||
var successMsg = [[${success}]];
|
||||
console.log("Success message:", successMsg);
|
||||
if (successMsg) {
|
||||
console.log("Showing alert");
|
||||
customPopups.showAlert(successMsg);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script th:replace="~{apps/mypage/components/commonUserInfo :: commonUserInfoScript}"></script>
|
||||
<script th:inline="javascript">
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const form = document.getElementById('updateForm');
|
||||
try {
|
||||
// 원래 값들 가져오기
|
||||
const originalName = document.querySelector('input[name="userName"]').defaultValue;
|
||||
const originalPrefix = document.querySelector('input[name="mobilePrefix"]').value;
|
||||
const originalMiddle = document.querySelector('input[name="mobileMiddle"]').value;
|
||||
const originalLast = document.querySelector('input[name="mobileLast"]').value;
|
||||
const originalMobileNumber = `${originalPrefix}-${originalMiddle}-${originalLast}`;
|
||||
|
||||
// 수정 버튼 이벤트
|
||||
const submitButton = document.querySelector('.btn-primary.submit-btn');
|
||||
if (submitButton) {
|
||||
submitButton.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
// 현재 값들 가져오기
|
||||
const currentName = document.querySelector('input[name="userName"]').value.trim();
|
||||
const newPhoneContainer = document.getElementById('newPhoneNumberContainer');
|
||||
const isNewPhoneVisible = newPhoneContainer && newPhoneContainer.style.display !== 'none';
|
||||
const mobileNumberInput = document.getElementById('mobileNumber');
|
||||
|
||||
try {
|
||||
// 원래 값들 가져오기
|
||||
const originalName = document.querySelector('input[name="userName"]').defaultValue;
|
||||
const originalPrefix = document.querySelector('input[name="mobilePrefix"]').value;
|
||||
const originalMiddle = document.querySelector('input[name="mobileMiddle"]').value;
|
||||
const originalLast = document.querySelector('input[name="mobileLast"]').value;
|
||||
const originalMobileNumber = `${originalPrefix}-${originalMiddle}-${originalLast}`;
|
||||
// 변경사항 체크
|
||||
let hasChanges = false;
|
||||
|
||||
// 현재 값들 가져오기
|
||||
const currentName = document.querySelector('input[name="userName"]').value.trim();
|
||||
const newPhoneContainer = document.getElementById('newPhoneNumberContainer');
|
||||
const isNewPhoneVisible = newPhoneContainer && newPhoneContainer.style.display !== 'none';
|
||||
const mobileNumberInput = document.getElementById('mobileNumber');
|
||||
// 이름 변경 체크
|
||||
if (currentName !== originalName) {
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
// 변경사항 체크
|
||||
let hasChanges = false;
|
||||
// 휴대폰 번호 변경 체크
|
||||
if (isNewPhoneVisible) {
|
||||
if (window.isAuthVerified) {
|
||||
const prefix = $('#phoneMobile').val();
|
||||
const middle = document.getElementById('newMobileMiddle').value;
|
||||
const last = document.getElementById('newMobileLast').value;
|
||||
const newMobileNumber = `${prefix}-${middle}-${last}`;
|
||||
|
||||
// 이름 변경 체크
|
||||
if (currentName !== originalName) {
|
||||
hasChanges = true;
|
||||
}
|
||||
if (newMobileNumber !== originalMobileNumber) {
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
// 휴대폰 번호 변경 체크
|
||||
if (isNewPhoneVisible) {
|
||||
if (window.isAuthVerified) {
|
||||
const prefix = $('#phoneMobile').val();
|
||||
const middle = document.getElementById('newMobileMiddle').value;
|
||||
const last = document.getElementById('newMobileLast').value;
|
||||
const newMobileNumber = `${prefix}-${middle}-${last}`;
|
||||
|
||||
if (newMobileNumber !== originalMobileNumber) {
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (prefix && prefix !== '선택' && middle && last) {
|
||||
mobileNumberInput.value = newMobileNumber;
|
||||
if (prefix && prefix !== '선택' && middle && last) {
|
||||
mobileNumberInput.value = newMobileNumber;
|
||||
} else {
|
||||
customPopups.showAlert('휴대폰 번호를 올바르게 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
customPopups.showAlert('휴대폰 번호를 올바르게 입력해주세요.');
|
||||
customPopups.showAlert('인증번호확인을 완료해주세요.');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
customPopups.showAlert('인증번호확인을 완료해주세요.');
|
||||
mobileNumberInput.value = originalMobileNumber;
|
||||
}
|
||||
|
||||
// 변경사항이 없는 경우
|
||||
if (!hasChanges) {
|
||||
customPopups.showAlert('변경된 내용이 없습니다.');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
mobileNumberInput.value = originalMobileNumber;
|
||||
|
||||
// 불필요한 필드 제거
|
||||
const fieldsToRemove = [
|
||||
'mobilePrefix', 'mobileMiddle', 'mobileLast',
|
||||
'newMobilePrefix', 'newMobileMiddle', 'newMobileLast',
|
||||
'authNumber', 'finalMobileNumber'
|
||||
];
|
||||
|
||||
fieldsToRemove.forEach(fieldName => {
|
||||
const elements = form.querySelectorAll(`[name="${fieldName}"]`);
|
||||
elements.forEach(element => element.remove());
|
||||
});
|
||||
|
||||
form.submit();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error during form submission:', error);
|
||||
customPopups.showAlert('처리 중 오류가 발생했습니다. 다시 시도해 주세요.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 변경사항이 없는 경우
|
||||
if (!hasChanges) {
|
||||
customPopups.showAlert('변경된 내용이 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 불필요한 필드 제거
|
||||
const fieldsToRemove = [
|
||||
'mobilePrefix', 'mobileMiddle', 'mobileLast',
|
||||
'newMobilePrefix', 'newMobileMiddle', 'newMobileLast',
|
||||
'authNumber', 'finalMobileNumber'
|
||||
];
|
||||
|
||||
fieldsToRemove.forEach(fieldName => {
|
||||
const elements = form.querySelectorAll(`[name="${fieldName}"]`);
|
||||
elements.forEach(element => element.remove());
|
||||
});
|
||||
|
||||
form.submit();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error during form submission:', error);
|
||||
customPopups.showAlert('처리 중 오류가 발생했습니다. 다시 시도해 주세요.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 법인전환 버튼 이벤트
|
||||
const businessTransferBtn = document.querySelector('.btn_business');
|
||||
if (businessTransferBtn) {
|
||||
businessTransferBtn.addEventListener('click', () => {
|
||||
customPopups.showConfirm(
|
||||
'법인 회원으로 전환하시겠습니까?',
|
||||
(confirmed) => {
|
||||
if (confirmed) {
|
||||
const transferForm = document.createElement('form');
|
||||
transferForm.method = 'GET';
|
||||
transferForm.action = '/mypage/org-transfer';
|
||||
document.body.appendChild(transferForm);
|
||||
transferForm.submit();
|
||||
// 법인전환 버튼 이벤트
|
||||
const businessTransferBtn = document.querySelector('.btn_business');
|
||||
if (businessTransferBtn) {
|
||||
businessTransferBtn.addEventListener('click', () => {
|
||||
customPopups.showConfirm(
|
||||
'법인 회원으로 전환하시겠습니까?',
|
||||
(confirmed) => {
|
||||
if (confirmed) {
|
||||
const transferForm = document.createElement('form');
|
||||
transferForm.method = 'GET';
|
||||
transferForm.action = '/mypage/org-transfer';
|
||||
document.body.appendChild(transferForm);
|
||||
transferForm.submit();
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// 회원탈퇴 버튼 이벤트
|
||||
const withdrawalBtn = document.querySelector('.withdrawal-link');
|
||||
if (withdrawalBtn) {
|
||||
withdrawalBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
customPopups.showWithdrawal();
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
<section layout:fragment="pagePopups">
|
||||
<th:block th:replace="~{fragment/popup/withdrawalPopup :: withdrawalPopup}"></th:block>
|
||||
</section>
|
||||
// 회원탈퇴 버튼 이벤트
|
||||
const withdrawalBtn = document.querySelector('.withdrawal-link');
|
||||
if (withdrawalBtn) {
|
||||
withdrawalBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
customPopups.showWithdrawal();
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
<section layout:fragment="pagePopups">
|
||||
<th:block th:replace="~{fragment/popup/withdrawalPopup :: withdrawalPopup}"></th:block>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
@@ -1,76 +1,78 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||
layout:decorate="~{layout/djbank_title_layout}">
|
||||
<html xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
|
||||
|
||||
<body>
|
||||
<section layout:fragment="title">
|
||||
<div class="page-title-banner">
|
||||
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||
<h1>이메일 인증</h1>
|
||||
</div>
|
||||
</section>
|
||||
<section layout:fragment="contentFragment">
|
||||
<div class="org-register-container">
|
||||
|
||||
<div class="common-title-bar">
|
||||
<h2 class="common-title">이메일 인증</h2>
|
||||
<section layout:fragment="title">
|
||||
<div class="page-title-banner">
|
||||
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||
<h1>이메일 인증</h1>
|
||||
</div>
|
||||
</section>
|
||||
<section layout:fragment="contentFragment">
|
||||
<div class="service-main">
|
||||
<div class="app-management-content">
|
||||
<div class="password-change-wrapper">
|
||||
<h2 class="page-outer-title">이메일 인증</h2>
|
||||
|
||||
<div class="register-form-container">
|
||||
<form id="verificationEmailForm" role="form" name="verificationEmailForm" method="post">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
|
||||
<input type="hidden" id="userId" name="userId" th:value="${userId}">
|
||||
<form id="verificationEmailForm" role="form" name="verificationEmailForm" method="post">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
|
||||
<input type="hidden" id="userId" name="userId" th:value="${userId}">
|
||||
|
||||
<div class="register-form">
|
||||
<!-- 이메일 주소 + 인증코드 받기 버튼 (한 줄) -->
|
||||
<div class="form-row" style="margin-bottom: 0">
|
||||
<div class="form-label-wrapper label-offset">
|
||||
<span class="form-label-text">이메일 주소</span>
|
||||
</div>
|
||||
<div class="form-field-wrapper input-with-button">
|
||||
<input type="email" id="email" name="email" class="form-input input-readonly"
|
||||
th:value="${email}" readonly disabled="disabled">
|
||||
<button type="button" class="btn-input-action btn-auth btn_send_code">인증코드 받기</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row" style="margin-bottom: 0">
|
||||
<div class="form-label-wrapper label-offset">
|
||||
<span class="form-label-text"> </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-label-wrapper label-offset">
|
||||
<span class="form-label-text">인증코드 입력</span> <span class="required-badge">필수</span>
|
||||
</div>
|
||||
<div class="form-field-wrapper input-with-button">
|
||||
<div class="auth-input-group">
|
||||
<input type="text" id="authCode" name="authCode" maxlength="6" pattern="[0-9]*"
|
||||
inputmode="numeric" class="form-input"
|
||||
placeholder="이메일로 받은 인증코드 6자리">
|
||||
<span class="auth-timer" id="certify_time">05:00</span>
|
||||
<div class="register-form-container">
|
||||
<!-- 안내 Notice Box -->
|
||||
<div class="info-notice-box" style="margin-bottom: 24px;">
|
||||
<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>
|
||||
<button type="button" class="btn-input-action btn-auth btn_verify_code">인증코드 확인</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn-submit btn-secondary">취소</button>
|
||||
<!-- 이메일 주소 행 -->
|
||||
<div class="form-row">
|
||||
<div class="form-label-wrapper">
|
||||
<span class="form-label-text">이메일 주소</span>
|
||||
</div>
|
||||
<div class="form-field-wrapper input-with-button">
|
||||
<input type="email" id="email" name="email" class="form-input input-readonly" th:value="${email}"
|
||||
readonly disabled="disabled" style="flex: 1;">
|
||||
<button type="button" class="btn-input-action btn-auth btn_send_code">인증코드 받기</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 인증번호 입력 행 (초기 숨김) -->
|
||||
<div class="form-row" id="authCodeContainer" style="display: none;">
|
||||
<div class="form-label-wrapper">
|
||||
<span class="form-label-text">인증코드 입력</span>
|
||||
</div>
|
||||
<div class="form-field-wrapper input-with-button">
|
||||
<div class="auth-input-group" style="flex: 1; position: relative;">
|
||||
<input type="text" id="authCode" name="authCode" maxlength="6" pattern="[0-9]*" inputmode="numeric"
|
||||
class="form-input" placeholder="이메일로 받은 인증코드 6자리" style="width: 100%; padding-right: 70px;">
|
||||
<span class="auth-timer" id="certify_time"
|
||||
style="position: absolute; right: 16px; top: 50%; transform: translateY(-50%); font-weight: 500; font-size: 13px;">05:00</span>
|
||||
</div>
|
||||
<button type="button" class="btn-input-action btn-auth btn_verify_code">인증코드 확인</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="form-actions" style="justify-content: flex-end; margin-top: 24px;">
|
||||
<div class="right-buttons">
|
||||
<button type="button" class="btn-cancel btn_cancel">취소</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</body>
|
||||
<th:block layout:fragment="contentScript">
|
||||
<script th:inline="javascript">
|
||||
$(document).ready(function() {
|
||||
$(document).ready(function () {
|
||||
let timerInterval;
|
||||
let resendTimerInterval;
|
||||
let isVerified = false;
|
||||
@@ -83,10 +85,10 @@
|
||||
const resendLimitSeconds = /*[[${@environment.getProperty('portal.auth.resend_limit_seconds', '60')}]]*/ 60;
|
||||
|
||||
// 취소 버튼 클릭 시 메인 페이지로 이동
|
||||
$('.btn_cancel').on('click', function() {
|
||||
$('.btn_cancel').on('click', function () {
|
||||
customPopups.showConfirm(
|
||||
'이메일 인증을 취소하고 메인 페이지로 이동하시겠습니까?',
|
||||
function() {
|
||||
function () {
|
||||
location.href = '/';
|
||||
}
|
||||
);
|
||||
@@ -170,7 +172,7 @@
|
||||
}
|
||||
|
||||
// 인증코드 받기 버튼 클릭
|
||||
$('.btn_send_code').on('click', function(e) {
|
||||
$('.btn_send_code').on('click', function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const email = $('#email').val();
|
||||
@@ -186,7 +188,7 @@
|
||||
headers: {
|
||||
[csrfHeaderName]: csrfToken
|
||||
},
|
||||
success: function(response) {
|
||||
success: function (response) {
|
||||
// 테스트 환경 인증번호 표시
|
||||
if (response.authNumber) {
|
||||
displayTestAuthNotice(response.authNumber);
|
||||
@@ -199,7 +201,7 @@
|
||||
startTimer(300); // 5분 타이머
|
||||
startResendTimer(); // 재발송 타이머 시작 (1분)
|
||||
},
|
||||
error: function(xhr) {
|
||||
error: function (xhr) {
|
||||
const errorMsg = xhr.responseJSON?.error || '인증코드 발송에 실패했습니다.';
|
||||
customPopups.showAlert(errorMsg);
|
||||
$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();
|
||||
|
||||
const authCode = $('#authCode').val().trim();
|
||||
@@ -235,7 +237,7 @@
|
||||
headers: {
|
||||
[csrfHeaderName]: csrfToken
|
||||
},
|
||||
success: function(response) {
|
||||
success: function (response) {
|
||||
clearInterval(timerInterval);
|
||||
clearInterval(resendTimerInterval);
|
||||
isVerified = true;
|
||||
@@ -249,11 +251,11 @@
|
||||
customPopups.showAlert('이메일 인증이 완료되었습니다!');
|
||||
|
||||
// 알림 확인 후 메인 페이지로 이동
|
||||
setTimeout(function() {
|
||||
setTimeout(function () {
|
||||
location.href = '/';
|
||||
}, 2000);
|
||||
},
|
||||
error: function(xhr) {
|
||||
error: function (xhr) {
|
||||
const errorMsg = xhr.responseJSON?.error || '인증코드가 일치하지 않습니다.';
|
||||
customPopups.showAlert(errorMsg);
|
||||
}
|
||||
@@ -261,11 +263,12 @@
|
||||
});
|
||||
|
||||
// 인증코드 입력 필드 숫자만 허용
|
||||
$('#authCode').on('input', function() {
|
||||
$('#authCode').on('input', function () {
|
||||
$(this).val($(this).val().replace(/[^0-9]/g, ''));
|
||||
});
|
||||
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
+56
-57
@@ -1,66 +1,59 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko" xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kbank_base_layout}">
|
||||
<body>
|
||||
<th:block th:fragment="commonUserInfo">
|
||||
<div class="org-form-group">
|
||||
<label class="org-form-label">
|
||||
이메일 아이디 <span class="required-badge">필수</span>
|
||||
</label>
|
||||
<div class="org-form-input-wrapper">
|
||||
<div class="org-input-row">
|
||||
<div class="org-compound-input">
|
||||
<input type="text" id="userId" class="org-form-input"
|
||||
th:classappend="${isInvited ? 'input-readonly' : ''}"
|
||||
maxlength="30"
|
||||
th:value="${emailId}"
|
||||
th:readonly="${isInvited}"
|
||||
th:placeholder="#{portalUser.Register.email}">
|
||||
<span class="separator">@</span>
|
||||
<input type="text" id="domain" class="org-form-input"
|
||||
th:classappend="${isInvited ? 'input-readonly' : ''}"
|
||||
maxlength="50"
|
||||
th:value="${domain}"
|
||||
th:readonly="${isInvited}"
|
||||
th:placeholder="#{portalUser.Register.domain}">
|
||||
</div>
|
||||
<button type="button" class="btn org-btn-check btn_check_email"
|
||||
th:classappend="${isInvited ? 'hidden' : ''}">
|
||||
중복체크
|
||||
</button>
|
||||
</div>
|
||||
<input type="hidden" id="loginId" name="loginId" th:value="${loginId}">
|
||||
<input type="hidden" name="registrationType" th:value="${registrationType}"/>
|
||||
<input type="hidden" id="registrationScenario" name="registrationScenario" value="new"/>
|
||||
<div id="email-validation" class="org-validation-message"></div>
|
||||
</div>
|
||||
</div>
|
||||
<html lang="ko" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||
layout:decorate="~{layout/kbank_base_layout}">
|
||||
|
||||
<!-- 이메일 인증 (중복체크 통과 후 노출) -->
|
||||
<div class="org-form-group" id="emailVerifyRow" style="display: none;">
|
||||
<label class="org-form-label">
|
||||
이메일 인증 <span class="required-badge">필수</span>
|
||||
</label>
|
||||
<div class="org-form-input-wrapper">
|
||||
<div class="org-input-row">
|
||||
<button type="button" class="btn org-btn-check" id="btnSendEmailCode">인증번호 발송</button>
|
||||
</div>
|
||||
<div class="org-input-row" id="emailCodeRow" style="display: none; margin-top: 8px;">
|
||||
<div class="org-compound-input" style="position: relative; flex: 1;">
|
||||
<input type="text" id="emailAuthCode" class="org-form-input" maxlength="6"
|
||||
inputmode="numeric" autocomplete="one-time-code" placeholder="인증번호 6자리">
|
||||
<span class="org-timer" id="emailCertifyTime"
|
||||
style="position: absolute; right: 12px; top: 50%; transform: translateY(-50%); color: #E11D48;">03:00</span>
|
||||
<body>
|
||||
<th:block th:fragment="commonUserInfo">
|
||||
<div class="org-form-group">
|
||||
<label class="org-form-label">
|
||||
이메일 아이디 <span class="required-badge">필수</span>
|
||||
</label>
|
||||
<div class="org-form-input-wrapper">
|
||||
<div class="org-input-row">
|
||||
<div class="org-compound-input email-input-group">
|
||||
<input type="text" id="userId" class="org-form-input"
|
||||
th:classappend="${isInvited ? 'input-readonly' : ''}" maxlength="30" th:value="${emailId}"
|
||||
th:readonly="${isInvited}" th:placeholder="#{portalUser.Register.email}">
|
||||
<span class="separator">@</span>
|
||||
<input type="text" id="domain" class="org-form-input"
|
||||
th:classappend="${isInvited ? 'input-readonly' : ''}" maxlength="50" th:value="${domain}"
|
||||
th:readonly="${isInvited}" th:placeholder="#{portalUser.Register.domain}">
|
||||
</div>
|
||||
<button type="button" class="btn-action-primary md" th:classappend="${isInvited ? 'hidden' : ''}">
|
||||
중복체크
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="btn org-btn-check" id="btnVerifyEmailCode">인증확인</button>
|
||||
</div>
|
||||
<input type="hidden" id="loginId" name="loginId" th:value="${loginId}">
|
||||
<input type="hidden" name="registrationType" th:value="${registrationType}" />
|
||||
<input type="hidden" id="registrationScenario" name="registrationScenario" value="new" />
|
||||
<div id="email-validation" class="org-validation-message"></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>
|
||||
</th:block>
|
||||
|
||||
<!-- 이메일 인증 (중복체크 통과 후 노출) -->
|
||||
<div class="org-form-group" id="emailVerifyRow" style="display: none;">
|
||||
<label class="org-form-label">
|
||||
이메일 인증 <span class="required-badge">필수</span>
|
||||
</label>
|
||||
<div class="org-form-input-wrapper">
|
||||
<div class="org-input-row">
|
||||
<button type="button" class="btn-action-primary md" id="btnSendEmailCode">인증번호 발송</button>
|
||||
</div>
|
||||
<div class="org-input-row" id="emailCodeRow" style="display: none; margin-top: 8px;">
|
||||
<div class="org-compound-input" style="position: relative; flex: 1;">
|
||||
<input type="text" id="emailAuthCode" class="org-form-input" maxlength="6" inputmode="numeric"
|
||||
autocomplete="one-time-code" placeholder="인증번호 6자리">
|
||||
<span class="org-timer" id="emailCertifyTime"
|
||||
style="position: absolute; right: 12px; top: 50%; transform: translateY(-50%); color: #E11D48;">03:00</span>
|
||||
</div>
|
||||
<button type="button" class="btn-action-primary md" id="btnVerifyEmailCode">인증확인</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</th:block>
|
||||
</body>
|
||||
<th:block>
|
||||
<script th:fragment="commonUserInfoScript" th:inline="javascript">
|
||||
@@ -197,7 +190,12 @@
|
||||
function setEmailAuthMsg(msg, isError) {
|
||||
var el = $('#email-auth-validation');
|
||||
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() {
|
||||
@@ -292,4 +290,5 @@
|
||||
|
||||
</script>
|
||||
</th:block>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
+132
-114
@@ -1,111 +1,124 @@
|
||||
<!DOCTYPE html>
|
||||
<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>
|
||||
<th:block th:fragment="newUserForm">
|
||||
<div id="newUserFields">
|
||||
<!-- 성명 -->
|
||||
<div class="org-form-group">
|
||||
<label class="org-form-label">
|
||||
성명 <span class="required-badge">필수</span>
|
||||
</label>
|
||||
<div class="org-form-input-wrapper">
|
||||
<input type="text" th:field="*{userName}" id="userName" class="org-form-input"
|
||||
th:placeholder="#{portalUser.Register.userName}">
|
||||
<div id="userName-validation" class="org-validation-message"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 휴대폰 번호 입력 -->
|
||||
<div class="org-form-group">
|
||||
<label class="org-form-label">
|
||||
휴대폰 번호 <span class="required-badge">필수</span>
|
||||
</label>
|
||||
<div class="org-form-input-wrapper">
|
||||
<div class="org-input-row">
|
||||
<div class="org-compound-input">
|
||||
<select class="org-form-select phone-number" id="phoneMobile">
|
||||
<option value="">선택</option>
|
||||
<option value="010">010</option>
|
||||
<option value="011">011</option>
|
||||
<option value="017">017</option>
|
||||
<option value="016">016</option>
|
||||
<option value="018">018</option>
|
||||
</select>
|
||||
<span class="separator">-</span>
|
||||
<input type="text" id="cellPhone1" maxlength="4" class="org-form-input phone-number"
|
||||
th:placeholder="#{portal.Register.phone1}">
|
||||
<span class="separator">-</span>
|
||||
<input type="text" id="cellPhone2" maxlength="4" class="org-form-input phone-number"
|
||||
th:placeholder="#{portal.Register.phone2}">
|
||||
</div>
|
||||
<button type="button" class="btn org-btn-check btn_auth">
|
||||
인증번호 받기
|
||||
</button>
|
||||
<th:block th:fragment="newUserForm">
|
||||
<div id="newUserFields">
|
||||
<!-- 성명 -->
|
||||
<div class="org-form-group">
|
||||
<label class="org-form-label">
|
||||
성명 <span class="required-badge">필수</span>
|
||||
</label>
|
||||
<div class="org-form-input-wrapper">
|
||||
<input type="text" th:field="*{userName}" id="userName" class="org-form-input"
|
||||
th:placeholder="#{portalUser.Register.userName}">
|
||||
<div id="userName-validation" class="org-validation-message"></div>
|
||||
</div>
|
||||
<input type="hidden" th:field="*{mobileNumber}" id="mobileNumber">
|
||||
<div id="mobile-validation" class="org-validation-message"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 인증번호 입력 -->
|
||||
<div class="org-form-group" id="authNumberContainer" style="display: none;">
|
||||
<label class="org-form-label">
|
||||
인증번호 입력 <span class="required-badge">필수</span>
|
||||
</label>
|
||||
<div class="org-form-input-wrapper">
|
||||
<div class="org-input-row">
|
||||
<div class="org-auth-input-group">
|
||||
<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>
|
||||
<span class="org-timer" id="certify_time">03:00</span>
|
||||
<!-- 휴대폰 번호 입력 -->
|
||||
<div class="org-form-group">
|
||||
<label class="org-form-label">
|
||||
휴대폰 번호 <span class="required-badge">필수</span>
|
||||
</label>
|
||||
<div class="org-form-input-wrapper">
|
||||
<div class="org-input-row">
|
||||
<div class="org-compound-input phone-input-group">
|
||||
<select class="org-form-select phone-number" id="phoneMobile">
|
||||
<option value="">선택</option>
|
||||
<option value="010">010</option>
|
||||
<option value="011">011</option>
|
||||
<option value="017">017</option>
|
||||
<option value="016">016</option>
|
||||
<option value="018">018</option>
|
||||
</select>
|
||||
<span class="separator">-</span>
|
||||
<input type="text" id="cellPhone1" maxlength="4" class="org-form-input phone-number"
|
||||
th:placeholder="#{portal.Register.phone1}">
|
||||
<span class="separator">-</span>
|
||||
<input type="text" id="cellPhone2" maxlength="4" class="org-form-input phone-number"
|
||||
th:placeholder="#{portal.Register.phone2}">
|
||||
</div>
|
||||
<button type="button" class="btn btn-action-primary md btn_auth">
|
||||
인증번호 받기
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="btn org-btn-check btn_verify_auth">
|
||||
인증번호 확인
|
||||
</button>
|
||||
<input type="hidden" th:field="*{mobileNumber}" id="mobileNumber">
|
||||
<div id="mobile-validation" class="org-validation-message"></div>
|
||||
</div>
|
||||
<div id="authNumber-validation" class="org-validation-message"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 비밀번호 -->
|
||||
<div class="org-form-group">
|
||||
<label class="org-form-label">
|
||||
비밀번호 <span class="required-badge">필수</span>
|
||||
</label>
|
||||
<div class="org-form-input-wrapper">
|
||||
<input type="password" name="password" id="password" class="org-form-input"
|
||||
th:placeholder="#{portalUser.Register.pass}">
|
||||
<input type="hidden" name="isPasswordValid" id="isPasswordValid"/>
|
||||
<div id="password-validation" class="org-validation-message"></div>
|
||||
<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>
|
||||
<li data-rule="letter" 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 class="policy-text">숫자 포함</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>
|
||||
</ul>
|
||||
<p class="password-policy-note">아이디, 휴대전화 번호는 비밀번호에 사용할 수 없습니다.</p>
|
||||
<!-- 인증번호 입력 -->
|
||||
<div class="org-form-group" id="authNumberContainer" style="display: none;">
|
||||
<label class="org-form-label">
|
||||
인증번호 입력 <span class="required-badge">필수</span>
|
||||
</label>
|
||||
<div class="org-form-input-wrapper">
|
||||
<div class="org-input-row">
|
||||
<div class="org-auth-input-group">
|
||||
<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>
|
||||
<span class="org-timer" id="certify_time">03:00</span>
|
||||
</div>
|
||||
<button type="button" class="btn org-btn-check btn_verify_auth">
|
||||
인증번호 확인
|
||||
</button>
|
||||
</div>
|
||||
<div id="authNumber-validation" class="org-validation-message"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 비밀번호 확인 -->
|
||||
<div class="org-form-group">
|
||||
<label class="org-form-label">
|
||||
비밀번호 확인 <span class="required-badge">필수</span>
|
||||
</label>
|
||||
<div class="org-form-input-wrapper">
|
||||
<input type="password" name="password2" id="password2" class="org-form-input"
|
||||
th:placeholder="#{portalUser.Register.passConfirm}">
|
||||
<input type="hidden" name="isPasswordMatch" id="isPasswordMatch"/>
|
||||
<div id="password-match-validation" class="org-validation-message"></div>
|
||||
<!-- 비밀번호 -->
|
||||
<div class="org-form-group">
|
||||
<label class="org-form-label">
|
||||
비밀번호 <span class="required-badge">필수</span>
|
||||
</label>
|
||||
<div class="org-form-input-wrapper">
|
||||
<input type="password" name="password" id="password" class="org-form-input"
|
||||
th:placeholder="#{portalUser.Register.pass}">
|
||||
<input type="hidden" name="isPasswordValid" id="isPasswordValid" />
|
||||
<div id="password-validation" class="org-validation-message"></div>
|
||||
<ul class="password-policy-checklist" data-password-input="password"
|
||||
data-context-loginid="loginId" data-context-mobile="mobileNumber">
|
||||
<li data-rule="length" class="is-idle"><span class="policy-icon"></span><span class="policy-text">영문/숫자/특수문자
|
||||
포함 8~50자</span></li>
|
||||
<li data-rule="letter" 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 class="policy-text">숫자
|
||||
포함</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</th:block>
|
||||
<!-- 비밀번호 확인 -->
|
||||
<div class="org-form-group">
|
||||
<label class="org-form-label">
|
||||
비밀번호 확인 <span class="required-badge">필수</span>
|
||||
</label>
|
||||
<div class="org-form-input-wrapper">
|
||||
<input type="password" name="password2" id="password2" class="org-form-input"
|
||||
th:placeholder="#{portalUser.Register.passConfirm}">
|
||||
<input type="hidden" name="isPasswordMatch" id="isPasswordMatch" />
|
||||
<div id="password-match-validation" class="org-validation-message"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</th:block>
|
||||
</body>
|
||||
<th:block>
|
||||
<script th:fragment="newUserScript">
|
||||
@@ -132,15 +145,19 @@
|
||||
if (mobileNumberInput) {
|
||||
mobileNumberInput.value = formattedNumber;
|
||||
}
|
||||
// hidden 값 변경은 input 이벤트가 없으므로 체크리스트(nomobile) 수동 재판정
|
||||
if (window.PasswordPolicy) {
|
||||
PasswordPolicy.refresh();
|
||||
}
|
||||
return formattedNumber;
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
let countdownInterval;
|
||||
let isAuthVerified = false;
|
||||
|
||||
// 휴대폰 번호 중간, 끝자리 입력 제한 및 형식화
|
||||
$('#cellPhone1').on('input', function() {
|
||||
$('#cellPhone1').on('input', function () {
|
||||
let value = $(this).val().replace(/[^0-9]/g, '');
|
||||
if (value.length > 4) {
|
||||
value = value.slice(0, 4);
|
||||
@@ -149,7 +166,7 @@
|
||||
combineMobileNumber();
|
||||
});
|
||||
|
||||
$('#cellPhone2').on('input', function() {
|
||||
$('#cellPhone2').on('input', function () {
|
||||
let value = $(this).val().replace(/[^0-9]/g, '');
|
||||
if (value.length > 4) {
|
||||
value = value.slice(0, 4);
|
||||
@@ -159,12 +176,12 @@
|
||||
});
|
||||
|
||||
// select 변경 감지
|
||||
$('#phoneMobile').on('change', function() {
|
||||
$('#phoneMobile').on('change', function () {
|
||||
combineMobileNumber();
|
||||
});
|
||||
|
||||
// 인증번호 입력 제한
|
||||
$('#authNumber').on('input', function() {
|
||||
$('#authNumber').on('input', function () {
|
||||
$(this).val($(this).val().replace(/[^0-9]/g, ''));
|
||||
});
|
||||
|
||||
@@ -299,7 +316,7 @@
|
||||
|
||||
let authNumberInput = document.getElementById('authNumber');
|
||||
if (authNumberInput) {
|
||||
authNumberInput.addEventListener('input', function() {
|
||||
authNumberInput.addEventListener('input', function () {
|
||||
this.value = this.value.replace(/\D/g, '');
|
||||
this.classList.toggle('is-invalid', this.value.length !== 6);
|
||||
});
|
||||
@@ -307,13 +324,13 @@
|
||||
|
||||
let userNameInput = document.getElementById('userName');
|
||||
if (userNameInput) {
|
||||
userNameInput.addEventListener('input', function() {
|
||||
userNameInput.addEventListener('input', function () {
|
||||
this.classList.toggle('is-invalid', this.value.trim().length === 0);
|
||||
});
|
||||
}
|
||||
|
||||
// 인증번호 요청 버튼 이벤트 리스너
|
||||
$('.btn_auth').on('click', function() {
|
||||
$('.btn_auth').on('click', function () {
|
||||
let mobileNumber = combineMobileNumber();
|
||||
if (!mobileNumber) {
|
||||
customPopups.showAlert('휴대폰 번호를 올바르게 입력해주세요.');
|
||||
@@ -330,17 +347,17 @@
|
||||
purpose: 'signup',
|
||||
_csrf: $('input[name="_csrf"]').val()
|
||||
},
|
||||
success: function(response) {
|
||||
success: function (response) {
|
||||
handleAuthNumberRequest(response);
|
||||
},
|
||||
error: function() {
|
||||
error: function () {
|
||||
customPopups.showAlert('처리 중 오류가 발생했습니다. 다시 시도해주세요.');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 인증번호 확인 버튼 이벤트 리스너
|
||||
$('.btn_verify_auth').on('click', function() {
|
||||
$('.btn_verify_auth').on('click', function () {
|
||||
let authNumber = $('#authNumber').val();
|
||||
if (authNumber.length !== 6) {
|
||||
customPopups.showAlert('6자리 인증번호를 입력해주세요.');
|
||||
@@ -355,17 +372,17 @@
|
||||
authNumber: authNumber,
|
||||
_csrf: $('input[name="_csrf"]').val()
|
||||
},
|
||||
success: function(response) {
|
||||
success: function (response) {
|
||||
handleAuthNumberVerification(response);
|
||||
},
|
||||
error: function() {
|
||||
error: function () {
|
||||
customPopups.showAlert('처리 중 오류가 발생했습니다. 다시 시도해주세요.');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 비밀번호 검증
|
||||
$('#password').on('blur', function() {
|
||||
$('#password').on('blur', function () {
|
||||
let password = $(this).val();
|
||||
if (!password) {
|
||||
return;
|
||||
@@ -380,21 +397,21 @@
|
||||
loginId: $('#loginId').val(),
|
||||
_csrf: $('input[name="_csrf"]').val()
|
||||
},
|
||||
success: function(response) {
|
||||
success: function (response) {
|
||||
let targetElement = $('#password-validation');
|
||||
targetElement.removeClass('success error');
|
||||
targetElement.addClass(response.valid ? 'success' : 'error');
|
||||
targetElement.text(response.message);
|
||||
$('#isPasswordValid').val(response.valid);
|
||||
},
|
||||
error: function() {
|
||||
error: function () {
|
||||
customPopups.showAlert('처리 중 오류가 발생했습니다. 다시 시도해주세요.');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 비밀번호 확인 검증
|
||||
$('#password2').on('blur', function() {
|
||||
$('#password2').on('blur', function () {
|
||||
let password2 = $(this).val();
|
||||
if (!password2) {
|
||||
return;
|
||||
@@ -408,14 +425,14 @@
|
||||
password2: password2,
|
||||
_csrf: $('input[name="_csrf"]').val()
|
||||
},
|
||||
success: function(response) {
|
||||
success: function (response) {
|
||||
let targetElement = $('#password-match-validation');
|
||||
targetElement.removeClass('success error');
|
||||
targetElement.addClass(response.valid ? 'success' : 'error');
|
||||
targetElement.text(response.message);
|
||||
$('#isPasswordMatch').val(response.valid);
|
||||
},
|
||||
error: function() {
|
||||
error: function () {
|
||||
customPopups.showAlert("처리 중 오류가 발생했습니다. 다시 시도해주세요.");
|
||||
}
|
||||
});
|
||||
@@ -423,4 +440,5 @@
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
@@ -24,45 +24,61 @@
|
||||
<!-- Alert Container -->
|
||||
<div id="alertContainer"></div>
|
||||
|
||||
<div class="org-section-header" style="margin-top: 60px;margin-bottom: 60px;">
|
||||
<h3>법인 회원 가입 후 서비스 또는 API 이용을 하실 수 있습니다.</h3>
|
||||
</div>
|
||||
|
||||
<!-- Agreement Section -->
|
||||
<th:block th:replace="~{apps/register/userAgreementContent :: agreementContent(${termsOfUse}, ${privacyCollect})}"></th:block>
|
||||
|
||||
<form name="portalOrg" id="registerForm" role="form" th:action="@{/signup/portalOrg}"
|
||||
th:object="${portalOrg}" method="post">
|
||||
|
||||
<!-- Corporate Basic Information Section -->
|
||||
<div class="org-section-header org-section-header--agreement">
|
||||
<h3>법인 기본 정보</h3>
|
||||
<span class="required-badge">필수 입력</span>
|
||||
</div>
|
||||
<th:block th:replace="~{apps/register/components/orgBasicInfoForm :: orgInfoForm}"></th:block>
|
||||
|
||||
<!-- Corporate Admin Information Section -->
|
||||
<div class="org-section-header org-section-header--agreement" style="margin-top: 48px;">
|
||||
<h3>법인 관리자 정보</h3>
|
||||
<span class="required-badge">필수 입력</span>
|
||||
</div>
|
||||
|
||||
<!-- Registration Card Wrapper -->
|
||||
<div class="register-card-wrapper">
|
||||
<!-- Info Notice -->
|
||||
<div class="org-info-notice">
|
||||
<ul>
|
||||
<li>법인관리자는 API 신청 및 개발자 초대 등 모든 권한을 보유합니다.</li>
|
||||
</ul>
|
||||
<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:if="${registrationType == 'corporate'}">
|
||||
<th:block th:replace="~{apps/register/components/orgUserInfoForm :: orgUserInfoForm}"></th:block>
|
||||
</th:block>
|
||||
<!-- Agreement Section -->
|
||||
<th:block th:replace="~{apps/register/userAgreementContent :: agreementContent(${termsOfUse}, ${privacyCollect})}"></th:block>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="org-action-buttons">
|
||||
<button type="button" class="btn btn-secondary" id="cancelButton">취소</button>
|
||||
<button type="button" class="btn btn-primary btn_register">가입신청</button>
|
||||
</div>
|
||||
</form>
|
||||
<form name="portalOrg" id="registerForm" role="form" th:action="@{/signup/portalOrg}"
|
||||
th:object="${portalOrg}" method="post">
|
||||
|
||||
<!-- Corporate Basic Information Section -->
|
||||
<div class="org-section-header org-section-header--agreement">
|
||||
<h3>법인 기본 정보</h3>
|
||||
<span class="required-badge">필수 입력</span>
|
||||
</div>
|
||||
<th:block th:replace="~{apps/register/components/orgBasicInfoForm :: orgInfoForm}"></th:block>
|
||||
|
||||
<!-- Corporate Admin Information Section -->
|
||||
<div class="org-section-header org-section-header--agreement" style="margin-top: 48px;">
|
||||
<h3>법인 관리자 정보</h3>
|
||||
<span class="required-badge">필수 입력</span>
|
||||
</div>
|
||||
|
||||
<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:if="${registrationType == 'corporate'}">
|
||||
<th:block th:replace="~{apps/register/components/orgUserInfoForm :: orgUserInfoForm}"></th:block>
|
||||
</th:block>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="org-action-buttons">
|
||||
<button type="button" class="btn btn-secondary" id="cancelButton">취소</button>
|
||||
<button type="button" class="btn btn-primary btn_register">가입신청</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Loading Overlay -->
|
||||
<div class="org-loading-overlay" id="loadingOverlay">
|
||||
|
||||
@@ -1,65 +1,80 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml"
|
||||
xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||
layout:decorate="~{layout/djbank_base_layout}">
|
||||
<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_base_layout}">
|
||||
|
||||
<body>
|
||||
<th:block layout:fragment="contentFragment">
|
||||
<div class="signup-selection-page">
|
||||
<div class="signup-selection-container">
|
||||
<div class="signup-selection-card">
|
||||
<!-- Logo -->
|
||||
<div class="signup-logo">
|
||||
<img th:src="@{/img/logo/logo_box.png}" alt="DJBank">
|
||||
</div>
|
||||
<th:block layout:fragment="contentFragment">
|
||||
<div class="signup-selection-page">
|
||||
<div class="signup-selection-container">
|
||||
<div class="signup-selection-card">
|
||||
<!-- Header Title -->
|
||||
<div class="signup-header">
|
||||
<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>
|
||||
|
||||
<!-- Message -->
|
||||
<p class="signup-message">DJBank API Portal 사용을 위해 회원 가입해 주세요.</p>
|
||||
<!-- Signup Cards -->
|
||||
<div class="signup-cards">
|
||||
<!-- Individual Signup Card -->
|
||||
<a th:href="@{/signup/portalUser}" class="signup-card-item individual-card">
|
||||
<div class="card-icon-wrapper">
|
||||
<svg width="36" height="36" viewBox="0 0 22 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<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"
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="card-text-wrapper">
|
||||
<h3 class="card-title">개인 회원가입</h3>
|
||||
<p class="card-desc">개인 개발자 및 API 연동 테스트를 원하시는 사용자를 위한 회원가입입니다.</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Signup Buttons -->
|
||||
<div class="signup-buttons">
|
||||
<!-- Individual Signup Button -->
|
||||
<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
|
||||
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"/>
|
||||
</svg>
|
||||
<span>개인회원 가입</span>
|
||||
</a>
|
||||
|
||||
<!-- Organization Signup Button -->
|
||||
<a th:href="@{/signup/portalOrg}" class="signup-btn signup-btn-organization">
|
||||
<svg width="26" height="30" viewBox="0 0 26 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<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"
|
||||
fill="white"/>
|
||||
</svg>
|
||||
<span>법인회원 가입</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Links -->
|
||||
<div class="signup-navigation">
|
||||
<a th:href="@{/}" class="signup-nav-link">홈으로</a>
|
||||
<span class="signup-nav-separator">|</span>
|
||||
<a th:href="@{/login}" class="signup-nav-link">로그인 하기</a>
|
||||
<!-- Organization Signup Card -->
|
||||
<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
|
||||
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="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="card-text-wrapper">
|
||||
<h3 class="card-title">법인 회원가입</h3>
|
||||
<p class="card-desc">기업 및 기관 파트너로서 정식 API 서비스 연동 및 운영을 위한 회원가입입니다.</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<!-- Navigation Links -->
|
||||
<div class="signup-navigation-box">
|
||||
<a th:href="@{/}" class="signup-nav-btn">홈으로</a>
|
||||
<div class="signup-nav-divider"></div>
|
||||
<a th:href="@{/login}" class="signup-nav-btn">로그인 하기</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</th:block>
|
||||
<th:block layout:fragment="script">
|
||||
<script th:src="@{/js/jquery-3.7.1.min.js}"></script>
|
||||
<script th:src="@{/js/daterangepicker.js}"></script>
|
||||
<script th:src="@{/js/slick.min.js}"></script>
|
||||
<script th:src="@{/js/front2.js}"></script>
|
||||
</th:block>
|
||||
<th:block layout:fragment="contentScript">
|
||||
<script>
|
||||
// Modern signup selection page - no custom JS needed
|
||||
// Card interactions handled by CSS and standard anchor tags
|
||||
</script>
|
||||
</th:block>
|
||||
</th:block>
|
||||
<th:block layout:fragment="script">
|
||||
<script th:src="@{/js/jquery-3.7.1.min.js}"></script>
|
||||
<script th:src="@{/js/daterangepicker.js}"></script>
|
||||
<script th:src="@{/js/slick.min.js}"></script>
|
||||
<script th:src="@{/js/front2.js}"></script>
|
||||
</th:block>
|
||||
<th:block layout:fragment="contentScript">
|
||||
<script>
|
||||
// Modern signup selection page - no custom JS needed
|
||||
// Card interactions handled by CSS and standard anchor tags
|
||||
</script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
@@ -1,79 +1,78 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||
layout:decorate="~{layout/djbank_title_layout}">
|
||||
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||
layout:decorate="~{layout/djbank_title_layout}">
|
||||
|
||||
<body>
|
||||
<section layout:fragment="title">
|
||||
<div class="page-title-banner">
|
||||
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||
<h1>회원가입</h1>
|
||||
</div>
|
||||
</section>
|
||||
<section layout:fragment="contentFragment">
|
||||
<div class="org-register-container">
|
||||
|
||||
<div class="common-title-bar">
|
||||
<h2 class="common-title">이메일 인증</h2>
|
||||
<section layout:fragment="title">
|
||||
<div class="page-title-banner">
|
||||
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||
<h1>회원가입</h1>
|
||||
</div>
|
||||
</section>
|
||||
<section layout:fragment="contentFragment">
|
||||
<div class="service-main">
|
||||
<div class="app-management-content">
|
||||
<div class="password-change-wrapper">
|
||||
<h2 class="page-outer-title">이메일 인증</h2>
|
||||
|
||||
<div class="register-form-container">
|
||||
<p class="form-hint" style="margin-bottom: 16px;">
|
||||
회원가입을 완료하려면 가입하신 이메일 주소로 인증을 진행해 주세요.
|
||||
</p>
|
||||
<form id="signupVerificationEmailForm" role="form" name="signupVerificationEmailForm" method="post">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
|
||||
<form id="signupVerificationEmailForm" role="form" name="signupVerificationEmailForm" method="post">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
|
||||
|
||||
<div class="register-form">
|
||||
<!-- 이메일 주소 + 인증코드 받기 버튼 (한 줄) -->
|
||||
<div class="form-row" style="margin-bottom: 0">
|
||||
<div class="form-label-wrapper label-offset">
|
||||
<span class="form-label-text">이메일 주소</span>
|
||||
</div>
|
||||
<div class="form-field-wrapper input-with-button">
|
||||
<input type="email" id="email" name="email" class="form-input input-readonly"
|
||||
th:value="${email}" readonly disabled="disabled">
|
||||
<button type="button" class="btn-input-action btn-auth btn_send_code">인증코드 받기</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row" style="margin-bottom: 0">
|
||||
<div class="form-label-wrapper label-offset">
|
||||
<span class="form-label-text"> </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-label-wrapper label-offset">
|
||||
<span class="form-label-text">인증코드 입력</span> <span class="required-badge">필수</span>
|
||||
</div>
|
||||
<div class="form-field-wrapper input-with-button">
|
||||
<div class="auth-input-group">
|
||||
<input type="text" id="authCode" name="authCode" maxlength="6" pattern="[0-9]*"
|
||||
inputmode="numeric" class="form-input"
|
||||
placeholder="이메일로 받은 인증코드 6자리">
|
||||
<span class="auth-timer" id="certify_time">05:00</span>
|
||||
<div class="register-form-container">
|
||||
<!-- 안내 Notice Box -->
|
||||
<div class="info-notice-box" style="margin-bottom: 24px;">
|
||||
<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>
|
||||
<button type="button" class="btn-input-action btn-auth btn_verify_code">인증코드 확인</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn-submit btn-secondary btn_cancel">나중에 인증</button>
|
||||
<!-- 이메일 주소 행 -->
|
||||
<div class="form-row">
|
||||
<div class="form-label-wrapper">
|
||||
<span class="form-label-text">이메일 주소</span>
|
||||
</div>
|
||||
<div class="form-field-wrapper input-with-button">
|
||||
<input type="email" id="email" name="email" class="form-input input-readonly" th:value="${email}"
|
||||
readonly disabled="disabled" style="flex: 1;">
|
||||
<button type="button" class="btn-input-action btn-auth btn_send_code">인증코드 받기</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 인증번호 입력 행 (초기 숨김) -->
|
||||
<div class="form-row" id="authCodeContainer" style="display: none;">
|
||||
<div class="form-label-wrapper">
|
||||
<span class="form-label-text">인증코드 입력</span>
|
||||
</div>
|
||||
<div class="form-field-wrapper input-with-button">
|
||||
<div class="auth-input-group" style="flex: 1; position: relative;">
|
||||
<input type="text" id="authCode" name="authCode" maxlength="6" pattern="[0-9]*" inputmode="numeric"
|
||||
class="form-input" placeholder="이메일로 받은 인증코드 6자리" style="width: 100%; padding-right: 70px;">
|
||||
<span class="auth-timer" id="certify_time"
|
||||
style="position: absolute; right: 16px; top: 50%; transform: translateY(-50%); font-weight: 500; font-size: 13px;">05:00</span>
|
||||
</div>
|
||||
<button type="button" class="btn-input-action btn-auth btn_verify_code">인증코드 확인</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="form-actions" style="justify-content: flex-end; margin-top: 24px;">
|
||||
<div class="right-buttons">
|
||||
<button type="button" class="btn-cancel btn_cancel">나중에 인증</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</body>
|
||||
<th:block layout:fragment="contentScript">
|
||||
<script th:inline="javascript">
|
||||
$(document).ready(function() {
|
||||
$(document).ready(function () {
|
||||
let timerInterval;
|
||||
let resendTimerInterval;
|
||||
let isVerified = false;
|
||||
@@ -86,10 +85,10 @@
|
||||
const resendLimitSeconds = /*[[${@environment.getProperty('portal.auth.resend_limit_seconds', '60')}]]*/ 60;
|
||||
|
||||
// "나중에 인증" 클릭 시: 가입은 완료된 상태이므로 로그인 페이지로 이동 (로그인 후 인증 유도)
|
||||
$('.btn_cancel').on('click', function() {
|
||||
$('.btn_cancel').on('click', function () {
|
||||
customPopups.showConfirm(
|
||||
'이메일 인증을 나중에 하시겠습니까? 로그인 후에도 인증을 완료할 수 있습니다.',
|
||||
function() {
|
||||
function () {
|
||||
location.href = /*[[@{/login}]]*/ '/login';
|
||||
}
|
||||
);
|
||||
@@ -173,7 +172,7 @@
|
||||
}
|
||||
|
||||
// 인증코드 받기 버튼 클릭
|
||||
$('.btn_send_code').on('click', function(e) {
|
||||
$('.btn_send_code').on('click', function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const $button = $(this);
|
||||
@@ -187,7 +186,7 @@
|
||||
headers: {
|
||||
[csrfHeaderName]: csrfToken
|
||||
},
|
||||
success: function(response) {
|
||||
success: function (response) {
|
||||
if (!response.valid) {
|
||||
customPopups.showAlert(response.message || '인증코드 발송에 실패했습니다.');
|
||||
$button.prop('disabled', false).text('인증코드 받기');
|
||||
@@ -206,7 +205,7 @@
|
||||
startTimer(300); // 5분 타이머
|
||||
startResendTimer(); // 재발송 타이머 시작
|
||||
},
|
||||
error: function() {
|
||||
error: function () {
|
||||
customPopups.showAlert('인증코드 발송에 실패했습니다.');
|
||||
$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();
|
||||
|
||||
const authCode = $('#authCode').val().trim();
|
||||
@@ -237,7 +236,7 @@
|
||||
headers: {
|
||||
[csrfHeaderName]: csrfToken
|
||||
},
|
||||
success: function(response) {
|
||||
success: function (response) {
|
||||
if (!response.valid) {
|
||||
customPopups.showAlert(response.message || '인증코드가 일치하지 않습니다.');
|
||||
return;
|
||||
@@ -256,22 +255,23 @@
|
||||
customPopups.showAlert('이메일 인증이 완료되었습니다! 로그인 후 이용해 주세요.');
|
||||
|
||||
// 알림 확인 후 로그인 페이지로 이동
|
||||
setTimeout(function() {
|
||||
setTimeout(function () {
|
||||
location.href = /*[[@{/login}]]*/ '/login';
|
||||
}, 2000);
|
||||
},
|
||||
error: function() {
|
||||
error: function () {
|
||||
customPopups.showAlert('인증코드 확인에 실패했습니다.');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 인증코드 입력 필드 숫자만 허용
|
||||
$('#authCode').on('input', function() {
|
||||
$('#authCode').on('input', function () {
|
||||
$(this).val($(this).val().replace(/[^0-9]/g, ''));
|
||||
});
|
||||
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
@@ -1,166 +1,291 @@
|
||||
<!DOCTYPE html>
|
||||
<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/base_layout}">
|
||||
<body>
|
||||
<th:block th:fragment="agreementContent(termsOfUse, privacyCollect)">
|
||||
<!-- Agreement Section Header -->
|
||||
<div class="org-section-header org-section-header--agreement">
|
||||
<h3 th:text="${agreementTitle} ?: '약관 동의'">약관 동의</h3>
|
||||
<span class="required-badge">필수 동의</span>
|
||||
</div>
|
||||
<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/base_layout}">
|
||||
|
||||
<form id="agreementForm" class="agreement-form">
|
||||
<!-- Agree All Checkbox -->
|
||||
<div class="agreement-all-section">
|
||||
<label class="agreement-checkbox-label">
|
||||
<input type="checkbox" id="agree_all" class="agreement-checkbox-input">
|
||||
<span class="agreement-checkbox-custom"></span>
|
||||
<span class="agreement-checkbox-text agreement-all-text">전체동의</span>
|
||||
</label>
|
||||
<body>
|
||||
<th:block th:fragment="agreementContent(termsOfUse, privacyCollect)">
|
||||
<!-- Agreement Section Header -->
|
||||
<div class="org-section-header org-section-header--agreement">
|
||||
<h3 th:text="${agreementTitle} ?: '약관 동의'">약관 동의</h3>
|
||||
<span class="required-badge">필수 동의</span>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="agreement-divider"></div>
|
||||
|
||||
<!-- Individual Agreements -->
|
||||
<div class="agreement-items-list">
|
||||
<!-- Terms of Use -->
|
||||
<div class="agreement-item-row">
|
||||
<form id="agreementForm" class="agreement-form">
|
||||
<!-- Agree All Checkbox -->
|
||||
<div class="agreement-all-section">
|
||||
<label class="agreement-checkbox-label">
|
||||
<input type="checkbox" name="termsOfUse" id="termsOfUse" class="agreement-checkbox-input" required>
|
||||
<input type="checkbox" id="agree_all" class="agreement-checkbox-input">
|
||||
<span class="agreement-checkbox-custom"></span>
|
||||
<span class="agreement-checkbox-text">
|
||||
<span class="agreement-required">[필수]</span>
|
||||
<span class="agreement-title">이용약관 동의</span>
|
||||
</span>
|
||||
<span class="agreement-checkbox-text agreement-all-text">전체동의</span>
|
||||
</label>
|
||||
<button type="button" class="agreement-toggle-icon" data-target="termsOfUseContent">
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="termsOfUseContent" class="agreement-content" style="display:none;">
|
||||
<div class="agreement-scroll">
|
||||
<div class="editor-content" th:utext="${termsOfUse?.contents ?: '이용약관 내용을 불러올 수 없습니다.'}"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Privacy Policy -->
|
||||
<div class="agreement-item-row">
|
||||
<label class="agreement-checkbox-label">
|
||||
<input type="checkbox" name="privacyCollect" id="privacyCollect" class="agreement-checkbox-input" required>
|
||||
<span class="agreement-checkbox-custom"></span>
|
||||
<th:block th:switch="${registrationType}">
|
||||
<span class="agreement-checkbox-text" th:case="'personal'">
|
||||
<span class="agreement-required">[필수]</span>
|
||||
<span class="agreement-title">개인정보 수집 및 이용 동의</span>
|
||||
</span>
|
||||
<span class="agreement-checkbox-text" th:case="'corporate'">
|
||||
<span class="agreement-required">[필수]</span>
|
||||
<span class="agreement-title">개인정보 수집 및 이용 동의</span>
|
||||
</span>
|
||||
</th:block>
|
||||
</label>
|
||||
<button type="button" class="agreement-toggle-icon" data-target="privacyCollectContent">
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="privacyCollectContent" class="agreement-content" style="display:none;">
|
||||
<div class="agreement-scroll">
|
||||
<div class="editor-content" th:utext="${privacyCollect?.contents ?: '개인정보수집동의서 내용을 불러올 수 없습니다.'}"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Divider -->
|
||||
<div class="agreement-divider"></div>
|
||||
|
||||
<!-- Notification Consent (모델에 notificationConsent가 있을 때만 노출) -->
|
||||
<th:block th:if="${notificationConsent != null}">
|
||||
<!-- Individual Agreements -->
|
||||
<div class="agreement-items-list">
|
||||
<!-- Terms of Use -->
|
||||
<div class="agreement-item-row">
|
||||
<label class="agreement-checkbox-label">
|
||||
<input type="checkbox" name="notificationConsent" id="notificationConsent" 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-text">
|
||||
<span class="agreement-required">[필수]</span>
|
||||
<span class="agreement-title">알림 수신 동의</span>
|
||||
<span class="agreement-title">이용약관 동의</span>
|
||||
</span>
|
||||
</label>
|
||||
<button type="button" class="agreement-toggle-icon" data-target="notificationConsentContent">
|
||||
<button type="button" class="agreement-toggle-icon" data-target="termsOfUseContent">
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="notificationConsentContent" class="agreement-content" style="display:none;">
|
||||
<div class="agreement-scroll">
|
||||
<div class="editor-content" th:utext="${notificationConsent?.contents ?: '알림 수신 동의서 내용을 불러올 수 없습니다.'}"></div>
|
||||
<div id="termsOfUseContent" class="agreement-content" style="display:none;">
|
||||
<div class="agreement-scroll" style="max-height: 400px; overflow-y: auto;">
|
||||
<div class="editor-content" th:utext="${termsOfUse?.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="termsOfUse" disabled>동의</button>
|
||||
</div>
|
||||
</div>
|
||||
</th:block>
|
||||
</div>
|
||||
</form>
|
||||
</th:block>
|
||||
|
||||
<script th:fragment="agreementScript">
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const agreeAll = document.getElementById('agree_all');
|
||||
const agreementChecks = document.querySelectorAll('.agreement-checkbox-input[required]');
|
||||
const toggleIcons = document.querySelectorAll('.agreement-toggle-icon');
|
||||
<!-- Privacy Policy -->
|
||||
<div class="agreement-item-row">
|
||||
<label class="agreement-checkbox-label">
|
||||
<input type="checkbox" name="privacyCollect" id="privacyCollect"
|
||||
class="agreement-checkbox-input" required disabled>
|
||||
<span class="agreement-checkbox-custom"></span>
|
||||
<th:block th:switch="${registrationType}">
|
||||
<span class="agreement-checkbox-text" th:case="'personal'">
|
||||
<span class="agreement-required">[필수]</span>
|
||||
<span class="agreement-title">개인정보 수집 및 이용 동의</span>
|
||||
</span>
|
||||
<span class="agreement-checkbox-text" th:case="'corporate'">
|
||||
<span class="agreement-required">[필수]</span>
|
||||
<span class="agreement-title">개인정보 수집 및 이용 동의</span>
|
||||
</span>
|
||||
</th:block>
|
||||
</label>
|
||||
<button type="button" class="agreement-toggle-icon" data-target="privacyCollectContent">
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="privacyCollectContent" class="agreement-content" style="display:none;">
|
||||
<div class="agreement-scroll" style="max-height: 400px; overflow-y: auto;">
|
||||
<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>
|
||||
|
||||
// 전체 동의 체크박스 이벤트
|
||||
if (agreeAll) {
|
||||
agreeAll.addEventListener('change', function () {
|
||||
agreementChecks.forEach(check => {
|
||||
check.checked = this.checked;
|
||||
});
|
||||
});
|
||||
}
|
||||
<!-- Notification Consent (모델에 notificationConsent가 있을 때만 노출) -->
|
||||
<th:block th:if="${notificationConsent != null}">
|
||||
<div class="agreement-item-row">
|
||||
<label class="agreement-checkbox-label">
|
||||
<input type="checkbox" name="notificationConsent" id="notificationConsent"
|
||||
class="agreement-checkbox-input" required disabled>
|
||||
<span class="agreement-checkbox-custom"></span>
|
||||
<span class="agreement-checkbox-text">
|
||||
<span class="agreement-required">[필수]</span>
|
||||
<span class="agreement-title">알림 수신 동의</span>
|
||||
</span>
|
||||
</label>
|
||||
<button type="button" class="agreement-toggle-icon" data-target="notificationConsentContent">
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="notificationConsentContent" class="agreement-content" style="display:none;">
|
||||
<div class="agreement-scroll" style="max-height: 400px; overflow-y: auto;">
|
||||
<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>
|
||||
</th:block>
|
||||
</div>
|
||||
</form>
|
||||
</th:block>
|
||||
|
||||
// 개별 체크박스 이벤트
|
||||
agreementChecks.forEach(check => {
|
||||
check.addEventListener('change', function () {
|
||||
if (agreeAll) {
|
||||
agreeAll.checked = Array.from(agreementChecks).every(c => c.checked);
|
||||
}
|
||||
});
|
||||
});
|
||||
<script th:fragment="agreementScript">
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const agreeAll = document.getElementById('agree_all');
|
||||
const agreementChecks = document.querySelectorAll('.agreement-checkbox-input[required]');
|
||||
const toggleIcons = document.querySelectorAll('.agreement-toggle-icon');
|
||||
|
||||
// 토글 아이콘 이벤트
|
||||
toggleIcons.forEach(icon => {
|
||||
icon.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
const targetId = this.getAttribute('data-target');
|
||||
const targetContent = document.getElementById(targetId);
|
||||
const parentRow = this.closest('.agreement-item-row');
|
||||
|
||||
// 현재 섹션이 이미 열려있는지 확인
|
||||
const isCurrentlyOpen = targetContent.style.display === 'block';
|
||||
|
||||
// 모든 섹션 닫기
|
||||
document.querySelectorAll('.agreement-content').forEach(content => {
|
||||
content.style.display = 'none';
|
||||
});
|
||||
document.querySelectorAll('.agreement-toggle-icon').forEach(button => {
|
||||
button.classList.remove('active');
|
||||
});
|
||||
document.querySelectorAll('.agreement-item-row').forEach(item => {
|
||||
item.classList.remove('active');
|
||||
});
|
||||
|
||||
// 현재 섹션이 닫혀있었다면 열기
|
||||
if (!isCurrentlyOpen) {
|
||||
targetContent.style.display = 'block';
|
||||
this.classList.add('active');
|
||||
if (parentRow) {
|
||||
parentRow.classList.add('active');
|
||||
// 전체동의 클릭 시 아직 다 읽지 않은 약관이 있으면 경고 알림 표시
|
||||
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('모든 약관을 상세히 펼쳐서 끝까지 스크롤하여 읽으신 후에 동의하실 수 있습니다.');
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
targetContent.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest'
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
agreeAll.addEventListener('change', function () {
|
||||
agreementChecks.forEach(check => {
|
||||
if (!check.disabled) {
|
||||
check.checked = this.checked;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 개별 체크박스 이벤트
|
||||
agreementChecks.forEach(check => {
|
||||
check.addEventListener('change', function () {
|
||||
if (agreeAll) {
|
||||
agreeAll.checked = Array.from(agreementChecks).every(c => c.checked);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 스크롤 이벤트 감지하여 동의 활성화
|
||||
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 => {
|
||||
icon.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const targetId = this.getAttribute('data-target');
|
||||
const targetContent = document.getElementById(targetId);
|
||||
const parentRow = this.closest('.agreement-item-row');
|
||||
|
||||
// 현재 섹션이 이미 열려있는지 확인
|
||||
const isCurrentlyOpen = targetContent.style.display === 'block';
|
||||
|
||||
// 모든 섹션 닫기
|
||||
document.querySelectorAll('.agreement-content').forEach(content => {
|
||||
content.style.display = 'none';
|
||||
});
|
||||
document.querySelectorAll('.agreement-toggle-icon').forEach(button => {
|
||||
button.classList.remove('active');
|
||||
});
|
||||
document.querySelectorAll('.agreement-item-row').forEach(item => {
|
||||
item.classList.remove('active');
|
||||
});
|
||||
|
||||
// 현재 섹션이 닫혀있었다면 열기
|
||||
if (!isCurrentlyOpen) {
|
||||
targetContent.style.display = 'block';
|
||||
this.classList.add('active');
|
||||
if (parentRow) {
|
||||
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(() => {
|
||||
targetContent.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest'
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 행 한줄 클릭시 토글 동작 (체크박스 클릭 제외)
|
||||
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>
|
||||
|
||||
</html>
|
||||
@@ -20,50 +20,58 @@
|
||||
<!-- Alert Container -->
|
||||
<div id="alertContainer"></div>
|
||||
|
||||
<!-- Info Notice -->
|
||||
<div class="org-info-notice" th:if="${!isInvited}">
|
||||
<ul>
|
||||
<li>서비스 또는 API 사용을 원하실 경우 법인회원 승인 후 이용하실 수 있습니다.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Agreement Section -->
|
||||
<th:block th:replace="~{apps/register/userAgreementContent :: agreementContent(${termsOfUse}, ${privacyCollect})}"></th:block>
|
||||
|
||||
<form name="portalUser" id="registerForm" role="form" th:action="@{/signup/portalUser}"
|
||||
th:object="${portalUser}" method="post">
|
||||
|
||||
<!-- Corporate Information Section (invited users only) -->
|
||||
<div th:if="${isInvited}">
|
||||
<div class="org-section-header org-section-header--agreement">
|
||||
<h3>법인 정보</h3>
|
||||
<!-- Registration Card Wrapper -->
|
||||
<div class="register-card-wrapper">
|
||||
<!-- Info Notice -->
|
||||
<div class="org-info-notice" th:if="${!isInvited}">
|
||||
<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 class="org-form-group">
|
||||
<label class="org-form-label">법인명</label>
|
||||
<div class="org-form-input-wrapper">
|
||||
<input type="text" name="companyName" class="org-form-input" th:value="${orgName}" readonly>
|
||||
<!-- Agreement Section -->
|
||||
<th:block th:replace="~{apps/register/userAgreementContent :: agreementContent(${termsOfUse}, ${privacyCollect})}"></th:block>
|
||||
|
||||
<form name="portalUser" id="registerForm" role="form" th:action="@{/signup/portalUser}"
|
||||
th:object="${portalUser}" method="post">
|
||||
|
||||
<!-- Corporate Information Section (invited users only) -->
|
||||
<div th:if="${isInvited}">
|
||||
<div class="org-section-header org-section-header--agreement">
|
||||
<h3>법인 정보</h3>
|
||||
</div>
|
||||
|
||||
<div class="org-form-group">
|
||||
<label class="org-form-label">법인명</label>
|
||||
<div class="org-form-input-wrapper">
|
||||
<input type="text" name="companyName" class="org-form-input" th:value="${orgName}" readonly>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Basic Information Section -->
|
||||
<div class="org-section-header org-section-header--agreement" th:style="${isInvited ? 'margin-top: 48px;' : ''}">
|
||||
<h3>기본 정보</h3>
|
||||
<span class="required-badge">필수 입력</span>
|
||||
</div>
|
||||
<!-- Basic Information Section -->
|
||||
<div class="org-section-header org-section-header--agreement" th:style="${isInvited ? 'margin-top: 48px;' : ''}">
|
||||
<h3>기본 정보</h3>
|
||||
<span class="required-badge">필수 입력</span>
|
||||
</div>
|
||||
|
||||
<div th:if="${registrationType == (isInvited ? 'corporate' : 'personal')}">
|
||||
<th:block th:replace="~{apps/register/components/commonUserInfoForm :: commonUserInfo}"></th:block>
|
||||
<th:block th:replace="~{apps/register/components/newUserInfoForm :: newUserForm}"></th:block>
|
||||
</div>
|
||||
<div th:if="${registrationType == (isInvited ? 'corporate' : 'personal')}">
|
||||
<th:block th:replace="~{apps/register/components/commonUserInfoForm :: commonUserInfo}"></th:block>
|
||||
<th:block th:replace="~{apps/register/components/newUserInfoForm :: newUserForm}"></th:block>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="org-action-buttons">
|
||||
<button type="button" class="btn btn-secondary" id="cancelButton">취소</button>
|
||||
<button type="button" class="btn btn-primary btn_register">가입신청</button>
|
||||
</div>
|
||||
</form>
|
||||
<!-- Action Buttons -->
|
||||
<div class="org-action-buttons">
|
||||
<button type="button" class="btn btn-secondary" id="cancelButton">취소</button>
|
||||
<button type="button" class="btn btn-primary btn_register">가입신청</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Loading Overlay -->
|
||||
<div class="org-loading-overlay" id="loadingOverlay">
|
||||
|
||||
@@ -206,6 +206,15 @@
|
||||
</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">
|
||||
<a th:href="@{/signup}" class="btn-action-primary">회원가입하러 가기</a>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
<span th:if="${statsAggregationMinute != null}"
|
||||
th:text="'통계 데이터는 매시 ' + ${statsAggregationMinute} + '분 집계되며, 집계 이전 시간 기준으로 생성됩니다.'">통계 안내</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"
|
||||
th:text="'※ 최신 집계 시각: ' + ${statsLatestTime} + ' 기준'">최신 집계 시각</span>
|
||||
</div>
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
<div class="s2-selection-container">
|
||||
<main class="s2-content-area">
|
||||
<form id="apiSelectorForm" method="post" th:action="@{${formAction}}" class="s2-form"
|
||||
th:attr="data-save-action=@{${saveAction}}, 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 -->
|
||||
<div class="s2-filter-header">
|
||||
|
||||
@@ -449,7 +449,7 @@
|
||||
}
|
||||
$('.loading-overlay').show();
|
||||
$.ajax({
|
||||
url: '/myapikey/api_key_request',
|
||||
url: '/clients/api_key_request',
|
||||
type: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify(requestData)
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
<li><a th:href="@{/partnership}">피드백/개선요청</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a href="#" class="nav-link">API Status</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
@@ -82,7 +83,7 @@
|
||||
<a th:href="@{/users}"><i class="fas fa-users"></i>개발자 관리</a>
|
||||
</li>
|
||||
<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="@{/statistics/api}"><i class="fas fa-chart-bar"></i>이용 통계</a>
|
||||
</li>
|
||||
@@ -228,6 +229,10 @@
|
||||
<li><a th:href="@{/partnership}">사업 제휴 문의</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<!-- API Status -->
|
||||
<li class="drawer-menu-item">
|
||||
<a href="#" class="drawer-menu-btn">API Status</a>
|
||||
</li>
|
||||
|
||||
<!-- 마이페이지 (Authenticated Only) -->
|
||||
<li class="drawer-menu-item has-submenu" sec:authorize="isAuthenticated()">
|
||||
@@ -239,7 +244,7 @@
|
||||
</button>
|
||||
<ul class="drawer-submenu">
|
||||
<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_APP')"><a th:href="@{/statistics/api}">이용 통계</a></li>
|
||||
<li><a th:href="@{/mypage}">내 정보 관리</a></li>
|
||||
|
||||
@@ -1,83 +1,76 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
|
||||
<body>
|
||||
<!-- Service Left Sidebar Fragment -->
|
||||
<aside class="service-sidebar" th:fragment="sidebar(activeMenu)">
|
||||
<nav class="service-nav">
|
||||
<!-- 서비스 소개 그룹 (Service) -->
|
||||
<th:block th:if="${activeMenu == 'intro' or activeMenu == 'guide' or activeMenu == 'oauth2' or activeMenu == 'webhookGuide'}">
|
||||
<a th:href="@{/service/intro}"
|
||||
th:classappend="${activeMenu == 'intro'} ? 'service-nav__item--active' : ''"
|
||||
class="service-nav__item">API 포탈 소개</a>
|
||||
<a th:href="@{/service/guide}"
|
||||
th:classappend="${activeMenu == 'guide'} ? 'service-nav__item--active' : ''"
|
||||
class="service-nav__item">회원가입 안내</a>
|
||||
<a th:href="@{/service/oauth2-guide}"
|
||||
th:classappend="${activeMenu == 'oauth2'} ? 'service-nav__item--active' : ''"
|
||||
class="service-nav__item">OAuth2 개발가이드</a>
|
||||
<a th:href="@{/service/webhook-dev-guide}"
|
||||
th:classappend="${activeMenu == 'webhookGuide'} ? 'service-nav__item--active' : ''"
|
||||
class="service-nav__item">웹훅 개발가이드</a>
|
||||
</th:block>
|
||||
<!-- Service Left Sidebar Fragment -->
|
||||
<aside class="service-sidebar" th:fragment="sidebar(activeMenu)">
|
||||
<nav class="service-nav">
|
||||
<!-- 서비스 소개 그룹 (Service) -->
|
||||
<th:block
|
||||
th:if="${activeMenu == 'intro' or activeMenu == 'guide' or activeMenu == 'oauth2' or activeMenu == 'webhookGuide'}">
|
||||
<a th:href="@{/service/intro}" th:classappend="${activeMenu == 'intro'} ? 'service-nav__item--active' : ''"
|
||||
class="service-nav__item">API 포탈 소개</a>
|
||||
<a th:href="@{/service/guide}" th:classappend="${activeMenu == 'guide'} ? 'service-nav__item--active' : ''"
|
||||
class="service-nav__item">회원가입 안내</a>
|
||||
<a th:href="@{/service/oauth2-guide}"
|
||||
th:classappend="${activeMenu == 'oauth2'} ? 'service-nav__item--active' : ''"
|
||||
class="service-nav__item">OAuth2 개발가이드</a>
|
||||
<a th:href="@{/service/webhook-dev-guide}"
|
||||
th:classappend="${activeMenu == 'webhookGuide'} ? 'service-nav__item--active' : ''"
|
||||
class="service-nav__item">웹훅 개발가이드</a>
|
||||
</th:block>
|
||||
|
||||
<!-- 고객지원 그룹 (Customer Support) -->
|
||||
<th:block th:if="${activeMenu == 'notice' or activeMenu == 'faq' or activeMenu == 'qna' or activeMenu == 'feedback'}">
|
||||
<a th:href="@{/portalnotice}"
|
||||
th:classappend="${activeMenu == 'notice'} ? 'service-nav__item--active' : ''"
|
||||
class="service-nav__item">공지사항</a>
|
||||
<a th:href="@{/faq_list}"
|
||||
th:classappend="${activeMenu == 'faq'} ? 'service-nav__item--active' : ''"
|
||||
class="service-nav__item">FAQ</a>
|
||||
<a th:href="@{/inquiry}"
|
||||
th:classappend="${activeMenu == 'qna'} ? 'service-nav__item--active' : ''"
|
||||
class="service-nav__item">Q&A</a>
|
||||
<a th:href="@{/partnership}"
|
||||
th:classappend="${activeMenu == 'feedback'} ? 'service-nav__item--active' : ''"
|
||||
class="service-nav__item">피드백/개선요청</a>
|
||||
</th:block>
|
||||
<!-- 고객지원 그룹 (Customer Support) -->
|
||||
<th:block
|
||||
th:if="${activeMenu == 'notice' or activeMenu == 'faq' or activeMenu == 'qna' or activeMenu == 'feedback'}">
|
||||
<a th:href="@{/portalnotice}" th:classappend="${activeMenu == 'notice'} ? 'service-nav__item--active' : ''"
|
||||
class="service-nav__item">공지사항</a>
|
||||
<a th:href="@{/faq_list}" th:classappend="${activeMenu == 'faq'} ? 'service-nav__item--active' : ''"
|
||||
class="service-nav__item">FAQ</a>
|
||||
<a th:href="@{/inquiry}" th:classappend="${activeMenu == 'qna'} ? 'service-nav__item--active' : ''"
|
||||
class="service-nav__item">Q&A</a>
|
||||
<a th:href="@{/partnership}" th:classappend="${activeMenu == 'feedback'} ? 'service-nav__item--active' : ''"
|
||||
class="service-nav__item">피드백/개선요청</a>
|
||||
</th:block>
|
||||
|
||||
<!-- 마이페이지 그룹 (My Page) -->
|
||||
<th:block th:if="${activeMenu == 'users' or activeMenu == 'apiKey' or activeMenu == 'statistics' or activeMenu == 'webhook' or activeMenu == 'profile' or activeMenu == 'password'}">
|
||||
<!-- Profile Section -->
|
||||
<div class="service-sidebar__profile">
|
||||
<div class="avatar">
|
||||
<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"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="user-name"><span th:text="${#authentication.principal.userName}">사용자</span> 님</p>
|
||||
</div>
|
||||
<!-- 마이페이지 그룹 (My Page) -->
|
||||
<th:block
|
||||
th:if="${activeMenu == 'users' or activeMenu == 'apiKey' or activeMenu == 'statistics' or activeMenu == 'webhook' or activeMenu == 'profile' or activeMenu == 'password'}">
|
||||
<!-- Profile Section -->
|
||||
<div class="service-sidebar__profile">
|
||||
<div class="avatar">
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="user-name"><span th:text="${#authentication.principal.userName}">사용자</span> 님</p>
|
||||
</div>
|
||||
|
||||
<div class="service-sidebar__divider"></div>
|
||||
<div class="service-sidebar__divider"></div>
|
||||
|
||||
<a th:href="@{/users}"
|
||||
th:classappend="${activeMenu == 'users'} ? 'service-nav__item--active' : ''"
|
||||
class="service-nav__item"
|
||||
sec:authorize="hasRole('ROLE_CORP_MANAGER')">개발자 관리</a>
|
||||
|
||||
<a th:href="@{/myapikey}"
|
||||
th:classappend="${activeMenu == 'apiKey'} ? 'service-nav__item--active' : ''"
|
||||
class="service-nav__item">앱 관리</a>
|
||||
|
||||
<a th:href="@{/webhook}"
|
||||
th:classappend="${activeMenu == 'webhook'} ? 'service-nav__item--active' : ''"
|
||||
class="service-nav__item"
|
||||
sec:authorize="hasRole('ROLE_WEBHOOK')">Webhook 관리</a>
|
||||
<a th:href="@{/users}" th:classappend="${activeMenu == 'users'} ? 'service-nav__item--active' : ''"
|
||||
class="service-nav__item" sec:authorize="hasRole('ROLE_CORP_MANAGER')">개발자 관리</a>
|
||||
|
||||
<a th:href="@{/statistics/api}"
|
||||
th:classappend="${activeMenu == 'statistics'} ? 'service-nav__item--active' : ''"
|
||||
class="service-nav__item"
|
||||
sec:authorize="hasRole('ROLE_APP')">이용통계</a>
|
||||
<a th:href="@{/clients}" th:classappend="${activeMenu == 'apiKey'} ? 'service-nav__item--active' : ''"
|
||||
class="service-nav__item">API 신청 관리</a>
|
||||
|
||||
<a th:href="@{/mypage}"
|
||||
th:classappend="${activeMenu == 'profile'} ? 'service-nav__item--active' : ''"
|
||||
class="service-nav__item">내정보 관리</a>
|
||||
<a th:href="@{/webhook}" th:classappend="${activeMenu == 'webhook'} ? 'service-nav__item--active' : ''"
|
||||
class="service-nav__item" sec:authorize="hasRole('ROLE_WEBHOOK')">Webhook 관리</a>
|
||||
|
||||
<a th:href="@{/password/change}"
|
||||
th:classappend="${activeMenu == 'password'} ? 'service-nav__item--active' : ''"
|
||||
class="service-nav__item">비밀번호 변경</a>
|
||||
</th:block>
|
||||
</nav>
|
||||
</aside>
|
||||
<a th:href="@{/statistics/api}"
|
||||
th:classappend="${activeMenu == 'statistics'} ? 'service-nav__item--active' : ''"
|
||||
class="service-nav__item" sec:authorize="hasRole('ROLE_APP')">이용통계</a>
|
||||
|
||||
<a th:href="@{/mypage}" th:classappend="${activeMenu == 'profile'} ? 'service-nav__item--active' : ''"
|
||||
class="service-nav__item">내정보 관리</a>
|
||||
|
||||
<a th:href="@{/password/change}"
|
||||
th:classappend="${activeMenu == 'password'} ? 'service-nav__item--active' : ''"
|
||||
class="service-nav__item">비밀번호 변경</a>
|
||||
</th:block>
|
||||
</nav>
|
||||
</aside>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
<!-- 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>
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<input type="tel"
|
||||
id="userInviteMobileInput"
|
||||
class="pop_input_field"
|
||||
placeholder="휴대폰 번호 ('-' 없이 입력)"
|
||||
placeholder="휴대폰 번호 (예: 010-1234-5678)"
|
||||
maxlength="13">
|
||||
<div id="userInvitePopupError" class="error-message"></div>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user