Compare commits
2 Commits
cff0199d2d
...
a456ed9bb0
| Author | SHA1 | Date | |
|---|---|---|---|
| a456ed9bb0 | |||
| 33c64972f8 |
@@ -133,6 +133,61 @@ public class GlobalControllerAdvice {
|
||||
"Portal", "customer.center.contact", "1588-3388", "고객센터 연락처");
|
||||
}
|
||||
|
||||
/**
|
||||
* 메인 페이지 본문에 노출되는 브랜드명. PortalProperty(Portal/brand.name)에서 조회.
|
||||
* 로고 이미지(alt 텍스트)는 별도이며 이 값의 영향을 받지 않는다.
|
||||
*/
|
||||
@ModelAttribute("brandName")
|
||||
public String brandName() {
|
||||
return portalPropertyService.getOrCreateProperty(
|
||||
"Portal", "brand.name", "DJBank", "메인 페이지 브랜드명 표기");
|
||||
}
|
||||
|
||||
/**
|
||||
* brandName 뒤에 바로 붙는 주격 조사(이/가). 받침 유무에 따라 관리자가 값을 바꿔도 문법이 깨지지 않도록 계산한다.
|
||||
*/
|
||||
@ModelAttribute("brandNameJosaGa")
|
||||
public String brandNameJosaGa() {
|
||||
return hasBatchim(brandName()) ? "이" : "가";
|
||||
}
|
||||
|
||||
/**
|
||||
* brandName 뒤에 바로 붙는 보조사(은/는).
|
||||
*/
|
||||
@ModelAttribute("brandNameJosaEun")
|
||||
public String brandNameJosaEun() {
|
||||
return hasBatchim(brandName()) ? "은" : "는";
|
||||
}
|
||||
|
||||
/**
|
||||
* 헤더(GNB) 로고 이미지 경로. PortalProperty(Portal/brand.logo.header.path)에서 조회.
|
||||
*/
|
||||
@ModelAttribute("brandLogoHeaderPath")
|
||||
public String brandLogoHeaderPath() {
|
||||
return portalPropertyService.getOrCreateProperty(
|
||||
"Portal", "brand.logo.header.path", "/img/logo/logo-djb.png", "헤더(GNB) 로고 이미지 경로");
|
||||
}
|
||||
|
||||
/**
|
||||
* 푸터 로고 이미지 경로. PortalProperty(Portal/brand.logo.footer.path)에서 조회.
|
||||
*/
|
||||
@ModelAttribute("brandLogoFooterPath")
|
||||
public String brandLogoFooterPath() {
|
||||
return portalPropertyService.getOrCreateProperty(
|
||||
"Portal", "brand.logo.footer.path", "/img/logo/logo-jjb.png", "푸터 로고 이미지 경로");
|
||||
}
|
||||
|
||||
private boolean hasBatchim(String word) {
|
||||
if (word == null || word.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
char last = word.charAt(word.length() - 1);
|
||||
if (last >= 0xAC00 && last <= 0xD7A3) {
|
||||
return (last - 0xAC00) % 28 != 0;
|
||||
}
|
||||
return "AEIOUaeiou".indexOf(last) < 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 푸터 관련 사이트 셀렉트 라벨. PortalProperty(Portal/footer.related-sites.label)에서 조회.
|
||||
*/
|
||||
|
||||
+66
-19
@@ -3,6 +3,7 @@ package com.eactive.apim.portal.djb.webhook.controller;
|
||||
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
|
||||
import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
|
||||
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
|
||||
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||
import com.eactive.apim.portal.common.exception.UserErrorMessageResolver;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
@@ -14,10 +15,15 @@ import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import javax.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.access.annotation.Secured;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.validation.BindingResult;
|
||||
@@ -50,10 +56,16 @@ public class WebhookController {
|
||||
|
||||
private static final int TOTAL_STEPS = 3;
|
||||
|
||||
/** 비밀번호 재인증 연속 실패 허용 횟수. 초과 시 세션 종료(강제 로그아웃) — StepUpPasswordController 답습. */
|
||||
private static final int MAX_PW_FAIL_COUNT = 5;
|
||||
/** 연속 실패 횟수 세션 attribute 키 */
|
||||
private static final String ATTR_PW_FAIL_COUNT = "WEBHOOK_PW_CONFIRM_FAIL_COUNT";
|
||||
|
||||
private final WebhookService webhookService;
|
||||
private final WebhookEventTypeProvider eventTypeProvider;
|
||||
private final ApiServiceService apiServiceService;
|
||||
private final AppServiceFacade appServiceFacade;
|
||||
private final UserSessionService userSessionService;
|
||||
|
||||
@ModelAttribute("webhookRegistration")
|
||||
public WebhookRegistrationDTO webhookRegistration() {
|
||||
@@ -211,6 +223,7 @@ public class WebhookController {
|
||||
|
||||
ModelAndView mav = new ModelAndView("apps/webhook/webhookModifyStep1");
|
||||
mav.addObject("eventTypes", eventTypeProvider.getAll());
|
||||
mav.addObject("userSecretSet", webhook.getUserSecretMasked() != null && !webhook.getUserSecretMasked().isEmpty());
|
||||
addStepModel(mav, 1);
|
||||
return mav;
|
||||
}
|
||||
@@ -297,33 +310,35 @@ public class WebhookController {
|
||||
|
||||
@PostMapping("/verify-secret")
|
||||
@ResponseBody
|
||||
public Map<String, Object> verifySecret(@RequestParam String password) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
if (!verifyPassword(password)) {
|
||||
result.put("success", false);
|
||||
result.put("message", "비밀번호가 일치하지 않습니다.");
|
||||
return result;
|
||||
public Map<String, Object> verifySecret(@RequestParam String password,
|
||||
HttpSession session, HttpServletRequest request, HttpServletResponse response) {
|
||||
Map<String, Object> failResult = checkPassword(password, session, request, response);
|
||||
if (failResult != null) {
|
||||
return failResult;
|
||||
}
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId());
|
||||
if (!webhook.isPresent()) {
|
||||
result.put("success", false);
|
||||
result.put("message", "등록된 Webhook이 없습니다.");
|
||||
return result;
|
||||
}
|
||||
Long webhookId = webhook.get().getId();
|
||||
result.put("success", true);
|
||||
result.put("secret", webhookService.getPlainSecret(webhook.get().getId(), currentOrgId()));
|
||||
result.put("secret", webhookService.getPlainSecret(webhookId, currentOrgId()));
|
||||
result.put("userSecret", webhookService.getPlainUserSecret(webhookId, currentOrgId()));
|
||||
return result;
|
||||
}
|
||||
|
||||
@PostMapping("/regenerate-secret")
|
||||
@ResponseBody
|
||||
public Map<String, Object> regenerateSecret(@RequestParam String password) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
if (!verifyPassword(password)) {
|
||||
result.put("success", false);
|
||||
result.put("message", "비밀번호가 일치하지 않습니다.");
|
||||
return result;
|
||||
public Map<String, Object> regenerateSecret(@RequestParam String password,
|
||||
HttpSession session, HttpServletRequest request, HttpServletResponse response) {
|
||||
Map<String, Object> failResult = checkPassword(password, session, request, response);
|
||||
if (failResult != null) {
|
||||
return failResult;
|
||||
}
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId());
|
||||
if (!webhook.isPresent()) {
|
||||
result.put("success", false);
|
||||
@@ -338,13 +353,13 @@ public class WebhookController {
|
||||
|
||||
@PostMapping("/delete")
|
||||
@ResponseBody
|
||||
public Map<String, Object> delete(@RequestParam String password) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
if (!verifyPassword(password)) {
|
||||
result.put("success", false);
|
||||
result.put("message", "비밀번호가 일치하지 않습니다.");
|
||||
return result;
|
||||
public Map<String, Object> delete(@RequestParam String password,
|
||||
HttpSession session, HttpServletRequest request, HttpServletResponse response) {
|
||||
Map<String, Object> failResult = checkPassword(password, session, request, response);
|
||||
if (failResult != null) {
|
||||
return failResult;
|
||||
}
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId());
|
||||
if (!webhook.isPresent()) {
|
||||
result.put("success", false);
|
||||
@@ -376,6 +391,38 @@ public class WebhookController {
|
||||
return appServiceFacade.verifyUserPassword(user, password);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 재인증 공통 체크. 성공(및 카운트 초기화) 시 {@code null}, 실패 시 즉시 응답할 결과 Map 을 반환한다.
|
||||
* 연속 실패가 {@link #MAX_PW_FAIL_COUNT} 회 이상이면 세션을 강제 종료하고 {@code forceLogout=true} 를 담는다
|
||||
* (무차별 대입 방어 — {@code StepUpPasswordController} 답습).
|
||||
*/
|
||||
private Map<String, Object> checkPassword(String password, HttpSession session,
|
||||
HttpServletRequest request, HttpServletResponse response) {
|
||||
if (verifyPassword(password)) {
|
||||
session.removeAttribute(ATTR_PW_FAIL_COUNT);
|
||||
return null;
|
||||
}
|
||||
|
||||
Integer count = (Integer) session.getAttribute(ATTR_PW_FAIL_COUNT);
|
||||
int failCount = (count == null ? 0 : count) + 1;
|
||||
session.setAttribute(ATTR_PW_FAIL_COUNT, failCount);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("success", false);
|
||||
if (failCount >= MAX_PW_FAIL_COUNT) {
|
||||
userSessionService.removeSession(session.getId());
|
||||
new SecurityContextLogoutHandler().logout(request, response,
|
||||
SecurityContextHolder.getContext().getAuthentication());
|
||||
result.put("forceLogout", true);
|
||||
result.put("message", "비밀번호 확인 5회 실패로 로그아웃되었습니다.");
|
||||
log.warn("Webhook 비밀번호 재인증 5회 실패로 강제 로그아웃 loginId={}", SecurityUtil.getCurrentLoginId());
|
||||
} else {
|
||||
result.put("message",
|
||||
"비밀번호가 일치하지 않습니다. (실패 " + failCount + "/" + MAX_PW_FAIL_COUNT + "회, 초과 시 자동 로그아웃됩니다)");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private String currentOrgId() {
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
return user != null && user.getPortalOrg() != null ? user.getPortalOrg().getId() : null;
|
||||
|
||||
@@ -16,6 +16,7 @@ public class WebhookDTO implements Serializable {
|
||||
private Long id;
|
||||
private String targetUrl;
|
||||
private String secretMasked;
|
||||
private String userSecretMasked;
|
||||
private String createdDate;
|
||||
|
||||
/** 구독 API ID 목록(폼 prefill 등 내부용). */
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.eactive.apim.portal.djb.webhook.dto;
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import javax.validation.constraints.Pattern;
|
||||
import lombok.Data;
|
||||
import org.hibernate.validator.constraints.Length;
|
||||
import org.hibernate.validator.constraints.NotBlank;
|
||||
@@ -27,6 +28,15 @@ public class WebhookRegistrationDTO implements Serializable {
|
||||
@Length(max = 255, message = "URL은 255자를 초과할 수 없습니다.")
|
||||
private String targetUrl;
|
||||
|
||||
/**
|
||||
* 사용자 지정 Secret(선택). Webhook 발송 시 HTTP 요청 헤더 값으로 그대로 echo 되므로
|
||||
* 출력 가능 ASCII(0x20~0x7E)만 허용한다 — 헤더는 non-ASCII/개행을 담을 수 없다.
|
||||
* 수정 시 공란이면 기존 값을 유지한다({@code WebhookService#update} 참조).
|
||||
*/
|
||||
@Length(max = 500, message = "값은 500자를 초과할 수 없습니다.")
|
||||
@Pattern(regexp = "^[\\x20-\\x7E]*$", message = "영문·숫자·특수문자 등 ASCII 문자만 입력 가능합니다(한글 등 유니코드 문자는 사용할 수 없습니다).")
|
||||
private String userSecret;
|
||||
|
||||
/** Step1: 구독 EventType 코드 목록 (TSEAIRM28 EVENT_TYPE). */
|
||||
private List<String> eventTypes = new ArrayList<>();
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import org.mapstruct.Mapping;
|
||||
public interface WebhookMapper {
|
||||
|
||||
@Mapping(target = "secretMasked", ignore = true)
|
||||
@Mapping(target = "userSecretMasked", ignore = true)
|
||||
@Mapping(target = "apiIds", ignore = true)
|
||||
@Mapping(target = "eventTypes", ignore = true)
|
||||
WebhookDTO toDto(WebhookRequest entity);
|
||||
|
||||
+4
@@ -48,6 +48,10 @@ public class WebhookRequest implements Serializable {
|
||||
@Column(name = "SECRET", length = 500)
|
||||
private String secret;
|
||||
|
||||
/** 사용자가 지정한 값. admin 발송 시 요청 헤더에 그대로 echo 된다 — SECRET 과 동일 이유로 평문 저장. */
|
||||
@Column(name = "USER_SECRET", length = 500)
|
||||
private String userSecret;
|
||||
|
||||
@Column(name = "CREATED_BY", length = 200)
|
||||
private String createdBy;
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@ public class WebhookService {
|
||||
request.setOrgId(orgId);
|
||||
request.setTargetUrl(dto.getTargetUrl().trim());
|
||||
request.setSecret(secret);
|
||||
request.setUserSecret(normalizeUserSecret(dto.getUserSecret()));
|
||||
WebhookRequest saved = requestRepository.save(request);
|
||||
|
||||
persistChildren(saved.getId(), dto);
|
||||
@@ -83,12 +84,17 @@ public class WebhookService {
|
||||
|
||||
/**
|
||||
* URL/API/EventType 수정. Secret 은 보존한다. 연관 테이블은 delete-all 후 재삽입.
|
||||
* userSecret 은 공란으로 제출되면 기존 값을 유지한다(마스킹 표시라 재입력 없이는 원본을 알 수 없으므로).
|
||||
*/
|
||||
public WebhookDTO update(Long id, WebhookRegistrationDTO dto, String orgId) {
|
||||
WebhookRequest request = loadOwned(id, orgId);
|
||||
validate(dto);
|
||||
|
||||
request.setTargetUrl(dto.getTargetUrl().trim());
|
||||
String userSecret = normalizeUserSecret(dto.getUserSecret());
|
||||
if (userSecret != null) {
|
||||
request.setUserSecret(userSecret);
|
||||
}
|
||||
requestRepository.save(request);
|
||||
|
||||
apiRepository.deleteByWebhookReqId(id);
|
||||
@@ -132,6 +138,14 @@ public class WebhookService {
|
||||
return loadOwned(id, orgId).getSecret();
|
||||
}
|
||||
|
||||
/**
|
||||
* 평문 사용자 지정 Secret 조회. 컨트롤러에서 비밀번호 재인증 후에만 호출한다.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public String getPlainUserSecret(Long id, String orgId) {
|
||||
return loadOwned(id, orgId).getUserSecret();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
private WebhookRequest loadOwned(Long id, String orgId) {
|
||||
@@ -168,6 +182,14 @@ public class WebhookService {
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeUserSecret(String raw) {
|
||||
if (raw == null) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = raw.trim();
|
||||
return trimmed.isEmpty() ? null : trimmed;
|
||||
}
|
||||
|
||||
private List<String> dedup(List<String> values) {
|
||||
if (values == null) {
|
||||
return java.util.Collections.emptyList();
|
||||
@@ -178,6 +200,8 @@ public class WebhookService {
|
||||
private WebhookDTO toDetailDto(WebhookRequest request) {
|
||||
WebhookDTO dto = webhookMapper.toDto(request);
|
||||
dto.setSecretMasked(request.getSecret() == null ? "" : SECRET_MASK);
|
||||
dto.setUserSecretMasked(request.getUserSecret() == null || request.getUserSecret().isEmpty()
|
||||
? "" : SECRET_MASK);
|
||||
|
||||
List<String> apiIds = apiRepository.findByWebhookReqId(request.getId()).stream()
|
||||
.map(WebhookRequestApi::getApiId)
|
||||
|
||||
@@ -908,7 +908,7 @@ hr {
|
||||
}
|
||||
.logo img {
|
||||
height: 32px;
|
||||
width: 114px;
|
||||
width: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -1247,7 +1247,7 @@ hr {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.mobile-drawer .drawer-welcome .btn-drawer-login:hover {
|
||||
background: rgb(0, 65.7, 162);
|
||||
background: rgb(0%, 25.7647058824%, 63.5294117647%);
|
||||
}
|
||||
.mobile-drawer .drawer-welcome.authenticated {
|
||||
flex-direction: row;
|
||||
@@ -2559,7 +2559,7 @@ hr {
|
||||
color: #FFFFFF;
|
||||
}
|
||||
.btn-success:hover {
|
||||
background: rgb(83.2897959184, 199.3102040816, 106.493877551);
|
||||
background: rgb(32.662665066%, 78.1608643457%, 41.762304922%);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
.btn-danger {
|
||||
@@ -2567,7 +2567,7 @@ hr {
|
||||
color: #FFFFFF;
|
||||
}
|
||||
.btn-danger:hover {
|
||||
background: rgb(255, 70.8, 70.8);
|
||||
background: rgb(100%, 27.7647058824%, 27.7647058824%);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
.btn-ghost {
|
||||
@@ -2833,7 +2833,7 @@ hr {
|
||||
.action-btn-delete:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
|
||||
background: rgb(255, 88.9, 88.9);
|
||||
background: rgb(100%, 34.862745098%, 34.862745098%);
|
||||
}
|
||||
.action-btn-delete:active {
|
||||
transform: translateY(0);
|
||||
@@ -2951,7 +2951,7 @@ hr {
|
||||
background: #a4d6ea;
|
||||
}
|
||||
.btn-input-action.btn-change:hover {
|
||||
background: rgb(131.6625, 199.4303571429, 226.5375);
|
||||
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
|
||||
}
|
||||
@@ -3026,7 +3026,7 @@ hr {
|
||||
border: none;
|
||||
}
|
||||
.btn-action-primary:hover {
|
||||
background: rgb(31.8731707317, 92.7219512195, 205.7268292683);
|
||||
background: rgb(12.4992826399%, 36.3615494978%, 80.6771879484%);
|
||||
transform: translateY(-2px);
|
||||
color: #fff;
|
||||
}
|
||||
@@ -3076,7 +3076,7 @@ hr {
|
||||
}
|
||||
.status-badge.status-processing {
|
||||
background: rgba(255, 217, 61, 0.1);
|
||||
color: rgb(221.2, 177.8721649485, 0);
|
||||
color: rgb(86.7450980392%, 69.7537901759%, 0%);
|
||||
}
|
||||
.status-badge.status-failed {
|
||||
background: rgba(255, 107, 107, 0.1);
|
||||
@@ -3116,7 +3116,7 @@ hr {
|
||||
}
|
||||
.status-badge-header.status-processing {
|
||||
background: rgba(255, 217, 61, 0.1);
|
||||
color: rgb(221.2, 177.8721649485, 0);
|
||||
color: rgb(86.7450980392%, 69.7537901759%, 0%);
|
||||
}
|
||||
|
||||
.badge-sm {
|
||||
@@ -4304,7 +4304,7 @@ select.form-control {
|
||||
.file-upload-wrapper .file-remove-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
|
||||
background: rgb(255, 88.9, 88.9);
|
||||
background: rgb(100%, 34.862745098%, 34.862745098%);
|
||||
}
|
||||
.file-upload-wrapper .file-remove-btn:active {
|
||||
transform: translateY(0);
|
||||
@@ -4625,7 +4625,7 @@ select.form-control {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.form-actions--with-withdrawal .withdrawal-link:hover {
|
||||
background: rgb(210.2090909091, 232.6045454545, 242.9409090909);
|
||||
background: rgb(82.4349376114%, 91.2174688057%, 95.2709447415%);
|
||||
}
|
||||
.form-actions--with-withdrawal .withdrawal-link img {
|
||||
width: 22px;
|
||||
@@ -4780,7 +4780,7 @@ select.form-control {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.notice-content-box a:hover {
|
||||
color: rgb(0, 65.7, 162);
|
||||
color: rgb(0%, 25.7647058824%, 63.5294117647%);
|
||||
}
|
||||
|
||||
.form-row--content .form-label-wrapper {
|
||||
@@ -5716,7 +5716,7 @@ select.form-control {
|
||||
font-size: 16px;
|
||||
}
|
||||
.drawer-logout-btn:hover {
|
||||
background: rgb(255, 70.8, 70.8);
|
||||
background: rgb(100%, 27.7647058824%, 27.7647058824%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(75, 155, 255, 0.15);
|
||||
}
|
||||
@@ -6429,7 +6429,7 @@ select.form-control {
|
||||
color: #64748b;
|
||||
}
|
||||
.list-table-btn--default:hover {
|
||||
background-color: rgb(233.3571428571, 233.3571428571, 231.1928571429);
|
||||
background-color: rgb(91.512605042%, 91.512605042%, 90.6638655462%);
|
||||
}
|
||||
.list-table-btn--primary {
|
||||
background-color: #ecf0fa;
|
||||
@@ -6437,7 +6437,7 @@ select.form-control {
|
||||
color: #2a69de;
|
||||
}
|
||||
.list-table-btn--primary:hover {
|
||||
background-color: rgb(216.7625, 224.8125, 244.9375);
|
||||
background-color: rgb(85.0049019608%, 88.1617647059%, 96.0539215686%);
|
||||
}
|
||||
.list-table-btn--secondary {
|
||||
background-color: #f5f5f4;
|
||||
@@ -6445,7 +6445,7 @@ select.form-control {
|
||||
color: #64748b;
|
||||
}
|
||||
.list-table-btn--secondary:hover {
|
||||
background-color: rgb(233.3571428571, 233.3571428571, 231.1928571429);
|
||||
background-color: rgb(91.512605042%, 91.512605042%, 90.6638655462%);
|
||||
}
|
||||
.list-table-btn--danger {
|
||||
background-color: #fbe7e9;
|
||||
@@ -6453,7 +6453,7 @@ select.form-control {
|
||||
color: #bb1026;
|
||||
}
|
||||
.list-table-btn--danger:hover {
|
||||
background-color: rgb(247.5571428571, 210.3428571429, 214.0642857143);
|
||||
background-color: rgb(97.081232493%, 82.487394958%, 83.9467787115%);
|
||||
}
|
||||
|
||||
.table-pagination {
|
||||
@@ -7103,7 +7103,7 @@ select.form-control {
|
||||
.alert.alert-error {
|
||||
background: rgba(255, 107, 107, 0.1);
|
||||
border: 1px solid rgba(255, 107, 107, 0.3);
|
||||
color: rgb(255, 70.8, 70.8);
|
||||
color: rgb(100%, 27.7647058824%, 27.7647058824%);
|
||||
align-items: center;
|
||||
}
|
||||
.alert.alert-error svg {
|
||||
@@ -7117,7 +7117,7 @@ select.form-control {
|
||||
.alert.alert-success {
|
||||
background: rgba(107, 207, 127, 0.1);
|
||||
border: 1px solid rgba(107, 207, 127, 0.3);
|
||||
color: rgb(61.5183673469, 189.6816326531, 87.1510204082);
|
||||
color: rgb(24.12484994%, 74.3849539816%, 34.1768707483%);
|
||||
}
|
||||
.alert.alert-info {
|
||||
background: rgba(0, 73, 180, 0.1);
|
||||
@@ -11488,10 +11488,10 @@ body.index-page-body {
|
||||
line-height: 20px;
|
||||
}
|
||||
.login-button:hover {
|
||||
background: rgb(25.65, 70.3, 173.85);
|
||||
background: rgb(10.0588235294%, 27.568627451%, 68.1764705882%);
|
||||
}
|
||||
.login-button:active {
|
||||
background: rgb(24.3, 66.6, 164.7);
|
||||
background: rgb(9.5294117647%, 26.1176470588%, 64.5882352941%);
|
||||
}
|
||||
.login-button:disabled {
|
||||
opacity: 0.6;
|
||||
@@ -11534,10 +11534,10 @@ body.index-page-body {
|
||||
border-bottom-right-radius: 8px;
|
||||
}
|
||||
.login-links-container .link-btn:hover {
|
||||
background: rgb(220.61, 227.85, 245.95);
|
||||
background: rgb(86.5137254902%, 89.3529411765%, 96.4509803922%);
|
||||
}
|
||||
.login-links-container .link-btn:active {
|
||||
background: rgb(205.22, 215.7, 241.9);
|
||||
background: rgb(80.4784313725%, 84.5882352941%, 94.862745098%);
|
||||
}
|
||||
|
||||
.login-alert {
|
||||
@@ -12046,12 +12046,12 @@ body.index-page-body {
|
||||
}
|
||||
.auth-request-button:hover,
|
||||
.auth-verify-button:hover {
|
||||
background: rgb(37.3117757009, 153.9304672897, 235.0082242991);
|
||||
background: rgb(14.6320689023%, 60.3648891332%, 92.1600879604%);
|
||||
transform: none !important;
|
||||
}
|
||||
.auth-request-button:active,
|
||||
.auth-verify-button:active {
|
||||
background: rgb(21.411588785, 146.3125233645, 233.148411215);
|
||||
background: rgb(8.3967014843%, 57.3774601429%, 91.4307494961%);
|
||||
}
|
||||
.auth-request-button:disabled,
|
||||
.auth-verify-button:disabled {
|
||||
@@ -12100,10 +12100,10 @@ body.index-page-body {
|
||||
background: #f0f2f5;
|
||||
}
|
||||
.account-recovery-card .form-actions .cancel-button:hover {
|
||||
background: rgb(225.45, 229.39, 235.3);
|
||||
background: rgb(88.4117647059%, 89.9568627451%, 92.2745098039%);
|
||||
}
|
||||
.account-recovery-card .form-actions .cancel-button:active {
|
||||
background: rgb(210.9, 216.78, 225.6);
|
||||
background: rgb(82.7058823529%, 85.0117647059%, 88.4705882353%);
|
||||
}
|
||||
.account-recovery-card .form-actions .submit-button {
|
||||
color: #FFFFFF;
|
||||
@@ -12113,7 +12113,7 @@ body.index-page-body {
|
||||
background: rgb(6, 54, 125);
|
||||
}
|
||||
.account-recovery-card .form-actions .submit-button:active {
|
||||
background: rgb(0, 65.7, 162);
|
||||
background: rgb(0%, 25.7647058824%, 63.5294117647%);
|
||||
}
|
||||
.account-recovery-card .form-actions .submit-button:disabled {
|
||||
opacity: 0.6;
|
||||
@@ -12349,7 +12349,7 @@ body.index-page-body {
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
.result-info-box .info-text .info-link:hover {
|
||||
color: rgb(0, 65.7, 162);
|
||||
color: rgb(0%, 25.7647058824%, 63.5294117647%);
|
||||
}
|
||||
@media (max-width: 576px) {
|
||||
.result-info-box .info-text {
|
||||
@@ -17549,7 +17549,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
.btn-copy-action:hover {
|
||||
background: rgb(131.6625, 199.4303571429, 226.5375);
|
||||
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.btn-copy-action {
|
||||
@@ -17576,7 +17576,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
.btn-view-secret:hover {
|
||||
background: rgb(31.8897196262, 151.4130841121, 234.5102803738);
|
||||
background: rgb(12.5057724024%, 59.377680044%, 91.9648158329%);
|
||||
}
|
||||
.btn-view-secret svg {
|
||||
width: 20px;
|
||||
@@ -17761,7 +17761,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
border-radius: 8px;
|
||||
}
|
||||
.btn-copy-action:hover {
|
||||
background: rgb(131.6625, 199.4303571429, 226.5375);
|
||||
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
|
||||
}
|
||||
.btn-view-secret {
|
||||
width: 100% !important;
|
||||
@@ -17777,7 +17777,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
height: 16px;
|
||||
}
|
||||
.btn-view-secret:hover {
|
||||
background: rgb(31.8897196262, 151.4130841121, 234.5102803738);
|
||||
background: rgb(12.5057724024%, 59.377680044%, 91.9648158329%);
|
||||
}
|
||||
#revealedSecretBox {
|
||||
width: 100%;
|
||||
@@ -18050,7 +18050,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.detail-wrap .dt-btn-copy:hover {
|
||||
background: rgb(189.6, 230.0857142857, 255);
|
||||
background: rgb(74.3529411765%, 90.2296918768%, 100%);
|
||||
}
|
||||
.detail-wrap .dt-btn-copy svg {
|
||||
color: #2a69de;
|
||||
@@ -18231,7 +18231,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
.detail-wrap .dt-btn-gray:hover {
|
||||
background: rgb(170.6589473684, 183.4378947368, 193.6610526316);
|
||||
background: rgb(66.9250773994%, 71.9364293086%, 75.9455108359%);
|
||||
}
|
||||
.detail-wrap .dt-btn-red {
|
||||
width: 156px;
|
||||
@@ -18249,7 +18249,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
.detail-wrap .dt-btn-red:hover {
|
||||
background: rgb(255, 70.0915337423, 64.24);
|
||||
background: rgb(100%, 27.4868759774%, 25.1921568627%);
|
||||
}
|
||||
.detail-wrap .dt-btn-blue {
|
||||
width: 156px;
|
||||
@@ -19823,7 +19823,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
}
|
||||
}
|
||||
.btn-inquiry-list:hover {
|
||||
background: rgb(215.8869565217, 218.8956521739, 224.9130434783);
|
||||
background: rgb(84.6615515772%, 85.8414322251%, 88.2011935209%);
|
||||
}
|
||||
.btn-inquiry-list:active {
|
||||
transform: scale(0.98);
|
||||
@@ -19856,7 +19856,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
}
|
||||
}
|
||||
.btn-inquiry-edit:hover {
|
||||
background: rgb(0, 69.35, 171);
|
||||
background: rgb(0%, 27.1960784314%, 67.0588235294%);
|
||||
}
|
||||
.btn-inquiry-edit:active {
|
||||
transform: scale(0.98);
|
||||
@@ -19889,7 +19889,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
}
|
||||
}
|
||||
.btn-inquiry-delete:hover {
|
||||
background: rgb(217.9841772152, 41.3658227848, 58.2873417722);
|
||||
background: rgb(85.4839910648%, 16.2218912882%, 22.8577810871%);
|
||||
}
|
||||
.btn-inquiry-delete:active {
|
||||
transform: scale(0.98);
|
||||
@@ -19961,7 +19961,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
margin-left: 8px;
|
||||
}
|
||||
.file-upload-inline .btn-remove-file-inline:hover {
|
||||
background: rgb(209.4151898734, 36.2848101266, 52.8721518987);
|
||||
background: rgb(82.1236038719%, 14.2293373045%, 20.7341772152%);
|
||||
}
|
||||
.file-upload-inline .btn-remove-file-inline svg {
|
||||
width: 12px;
|
||||
@@ -19993,7 +19993,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
}
|
||||
}
|
||||
.file-upload-inline .btn-file-attach:hover {
|
||||
background: rgb(37.3117757009, 153.9304672897, 235.0082242991);
|
||||
background: rgb(14.6320689023%, 60.3648891332%, 92.1600879604%);
|
||||
}
|
||||
.file-upload-inline .btn-file-attach svg {
|
||||
width: 22px;
|
||||
@@ -20053,7 +20053,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
border: none;
|
||||
}
|
||||
.inquiry-form-container .form-actions .btn-secondary:hover {
|
||||
background: rgb(215.8869565217, 218.8956521739, 224.9130434783);
|
||||
background: rgb(84.6615515772%, 85.8414322251%, 88.2011935209%);
|
||||
}
|
||||
.inquiry-form-container .form-actions .btn-primary {
|
||||
background: #0049b4;
|
||||
@@ -20061,7 +20061,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
border: none;
|
||||
}
|
||||
.inquiry-form-container .form-actions .btn-primary:hover {
|
||||
background: rgb(0, 69.35, 171);
|
||||
background: rgb(0%, 27.1960784314%, 67.0588235294%);
|
||||
}
|
||||
.inquiry-form-container .file-upload-inline .file-input-display {
|
||||
min-height: 50px;
|
||||
@@ -20850,7 +20850,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
cursor: pointer;
|
||||
}
|
||||
.djb-board-write-container .form-actions .btn-submit:hover {
|
||||
background-color: rgb(33.643902439, 97.8731707317, 217.156097561);
|
||||
background-color: rgb(13.193687231%, 38.3816355811%, 85.1592539455%);
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.djb-board-write-container .form-actions .btn-submit {
|
||||
@@ -21386,7 +21386,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.org-file-remove:hover {
|
||||
background: rgb(255, 70.8, 70.8);
|
||||
background: rgb(100%, 27.7647058824%, 27.7647058824%);
|
||||
}
|
||||
|
||||
.org-file-notice {
|
||||
@@ -22427,7 +22427,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
}
|
||||
.status-indicator.status-active {
|
||||
background-color: rgba(107, 207, 127, 0.1);
|
||||
color: rgb(83.2897959184, 199.3102040816, 106.493877551);
|
||||
color: rgb(32.662665066%, 78.1608643457%, 41.762304922%);
|
||||
}
|
||||
.status-indicator.status-active .status-dot {
|
||||
background-color: #6BCF7F;
|
||||
@@ -23386,81 +23386,302 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
}
|
||||
|
||||
.service-intro {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #fff;
|
||||
color: #000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
.service-intro__title {
|
||||
margin: 0;
|
||||
font-size: 25px;
|
||||
line-height: 1.4;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.service-intro__lead {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.service-intro__desc {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.service-intro__spacer {
|
||||
height: 10px;
|
||||
}
|
||||
.service-intro__section-title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.service-intro__section-body {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.service-intro__section-body p {
|
||||
margin: 0;
|
||||
}
|
||||
.service-intro__section-body p + p {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.service-intro__list {
|
||||
margin: 0;
|
||||
padding-left: 22px;
|
||||
list-style: disc;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.service-intro__list li + li {
|
||||
margin-top: 4px;
|
||||
color: #1e2939;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.service-intro {
|
||||
padding: 16px;
|
||||
gap: 16px;
|
||||
}
|
||||
.service-intro__title {
|
||||
font-size: 22px;
|
||||
}
|
||||
.service-intro__lead {
|
||||
.intro-callout {
|
||||
background: #0B2A5B;
|
||||
border-radius: 16px;
|
||||
padding: 34px 40px;
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
align-items: center;
|
||||
box-shadow: 0 14px 34px rgba(11, 42, 91, 0.18);
|
||||
}
|
||||
.intro-callout__icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
flex: none;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.intro-callout ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 11px;
|
||||
}
|
||||
.intro-callout li {
|
||||
position: relative;
|
||||
padding-left: 15px;
|
||||
color: #C2D4EA;
|
||||
font-size: 15.5px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.intro-callout li::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 10px;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: #00acdd;
|
||||
}
|
||||
.intro-callout li strong {
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.intro-section__eyebrow {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 2.4px;
|
||||
color: #0049b4;
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
.intro-section__title {
|
||||
font-size: 30px;
|
||||
font-weight: 900;
|
||||
letter-spacing: -1px;
|
||||
color: #0B2A5B;
|
||||
margin: 0;
|
||||
}
|
||||
.intro-section__lead {
|
||||
margin-top: 14px;
|
||||
font-size: 16px;
|
||||
color: #4a5565;
|
||||
line-height: 1.85;
|
||||
max-width: 830px;
|
||||
}
|
||||
.intro-section + .intro-section {
|
||||
margin-top: 76px;
|
||||
}
|
||||
|
||||
.intro-who {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 18px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
.intro-who__item {
|
||||
border: 1px solid #f3f4f6;
|
||||
border-radius: 14px;
|
||||
padding: 22px 20px;
|
||||
text-align: center;
|
||||
background: #fff;
|
||||
}
|
||||
.intro-who__item p {
|
||||
margin-top: 6px;
|
||||
font-size: 13.6px;
|
||||
color: #4a5565;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.intro-who__title {
|
||||
margin-top: 12px;
|
||||
font-size: 15.5px;
|
||||
font-weight: 800;
|
||||
color: #0B2A5B;
|
||||
}
|
||||
|
||||
.intro-grid3 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.intro-card {
|
||||
border: 1px solid #f3f4f6;
|
||||
border-radius: 16px;
|
||||
padding: 26px;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.intro-card__icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 11px;
|
||||
background: #EFF6FD;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.intro-card h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: #0B2A5B;
|
||||
letter-spacing: -0.5px;
|
||||
margin: 0;
|
||||
}
|
||||
.intro-card p {
|
||||
margin-top: 9px;
|
||||
font-size: 14.6px;
|
||||
color: #4a5565;
|
||||
line-height: 1.75;
|
||||
}
|
||||
.intro-card code {
|
||||
font-family: "Fira Code", monospace;
|
||||
font-size: 13px;
|
||||
background: #EFF4FA;
|
||||
color: #0049b4;
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.intro-card__tag {
|
||||
display: inline-block;
|
||||
margin-top: 14px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: #0049b4;
|
||||
background: #EEF6FD;
|
||||
border-radius: 6px;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.intro-diagram {
|
||||
margin-top: 28px;
|
||||
border: 1px solid #f3f4f6;
|
||||
border-radius: 16px;
|
||||
padding: 30px 24px;
|
||||
background: #FAFCFF;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.intro-diagram svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 700px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.intro-steps {
|
||||
margin-top: 34px;
|
||||
position: relative;
|
||||
padding-left: 38px;
|
||||
}
|
||||
.intro-steps::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
top: 14px;
|
||||
bottom: 14px;
|
||||
width: 2px;
|
||||
background: #D8E5F3;
|
||||
}
|
||||
|
||||
.intro-step {
|
||||
display: flex;
|
||||
gap: 22px;
|
||||
margin-bottom: 18px;
|
||||
position: relative;
|
||||
}
|
||||
.intro-step::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -38px;
|
||||
top: 34px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #0049b4;
|
||||
border: 3px solid #fff;
|
||||
box-shadow: 0 0 0 3px #D9E7F7;
|
||||
}
|
||||
.intro-step__icon {
|
||||
width: 88px;
|
||||
height: 82px;
|
||||
flex: none;
|
||||
border: 1px solid #f3f4f6;
|
||||
border-radius: 14px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #fff;
|
||||
}
|
||||
.intro-step__body {
|
||||
flex: 1;
|
||||
border: 1px solid #f3f4f6;
|
||||
border-radius: 14px;
|
||||
padding: 19px 26px;
|
||||
background: #fff;
|
||||
}
|
||||
.intro-step__body p {
|
||||
margin-top: 5px;
|
||||
font-size: 14.4px;
|
||||
color: #4a5565;
|
||||
}
|
||||
.intro-step__title {
|
||||
font-size: 17px;
|
||||
}
|
||||
.service-intro__section-title {
|
||||
font-size: 17px;
|
||||
}
|
||||
.service-intro__desc, .service-intro__section-body, .service-intro__list {
|
||||
font-weight: 800;
|
||||
color: #0B2A5B;
|
||||
}
|
||||
.intro-step__title em {
|
||||
font-style: normal;
|
||||
color: #0049b4;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.6px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.intro-cta {
|
||||
margin-top: 64px;
|
||||
border-radius: 18px;
|
||||
padding: 38px 44px;
|
||||
background: linear-gradient(100deg, #EAF4FD, #DFEDFB);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
border: 1px solid #D5E6F7;
|
||||
}
|
||||
.intro-cta__body h3 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
color: #0B2A5B;
|
||||
}
|
||||
.intro-cta__body p {
|
||||
margin-top: 6px;
|
||||
font-size: 15px;
|
||||
color: #4a5565;
|
||||
}
|
||||
.intro-cta .btn-action-primary {
|
||||
margin-left: auto;
|
||||
flex: none;
|
||||
font-size: 15.5px;
|
||||
padding: 15px 30px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.intro-who,
|
||||
.intro-grid3 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.intro-cta {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.intro-cta .btn-action-primary {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.intro-who,
|
||||
.intro-grid3 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.intro-callout {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.intro-section__title {
|
||||
font-size: 24px;
|
||||
}
|
||||
.intro-step {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
.service-main {
|
||||
@@ -26189,7 +26410,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
}
|
||||
.step1-wrap .s1-form-card .webhook-info-group .secret-action-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -26200,24 +26421,24 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
}
|
||||
.step1-wrap .s1-form-card .webhook-info-group .secret-box {
|
||||
flex: 1;
|
||||
max-width: 250px;
|
||||
height: 48px;
|
||||
min-width: 0;
|
||||
min-height: 48px;
|
||||
background-color: #efefef;
|
||||
border-radius: 10px;
|
||||
padding: 0 20px;
|
||||
padding: 12px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: #4e5968;
|
||||
font-weight: 500;
|
||||
box-sizing: border-box;
|
||||
letter-spacing: 1px;
|
||||
word-break: break-all;
|
||||
white-space: normal;
|
||||
border: 1px solid #DFDFDF;
|
||||
}
|
||||
@media (max-width: 576px) {
|
||||
.step1-wrap .s1-form-card .webhook-info-group .secret-box {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -8,11 +8,13 @@
|
||||
* - 기선택: window.API_SELECTOR_SELECTED / 목록 URL: window.API_SELECTOR_LIST_URL (fragment 인라인 주입)
|
||||
* - "이전" 버튼: 호출 페이지의 #btnPrevStep (없으면 스킵)
|
||||
* - 카트/모달: fragment `apiSelectorPopups` 를 pagePopups 슬롯에서 호출(body 직속)
|
||||
* - 페이징: #apiPagination (PAGE_SIZE 건/페이지) — 카테고리/검색은 재조회 없이 클라이언트에서 처리
|
||||
*
|
||||
* design(figma s2) 인라인 스크립트 대비 패치 3건:
|
||||
* design(figma s2) 인라인 스크립트 대비 패치 4건:
|
||||
* 1) 모달 열 때마다 updateModalList() 재빌드 — 세션 복원 직후(카드 렌더 전) 빈 모달 방지
|
||||
* 2) 모달 리스트를 DOM 체크박스가 아닌 selectedApis Set 기준으로 생성 — 미렌더/타 카테고리 누락 방지
|
||||
* 3) 제출/이전 시 DOM에 없는 선택분을 hidden input으로 주입 — 카테고리 필터 상태 전송 유실 방지
|
||||
* 4) 클라이언트 페이징 — 카드는 현재 페이지분만 DOM 렌더, 검색/전체선택/모달은 필터된 전체 목록 기준으로 동작
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('apiSelectorForm');
|
||||
@@ -20,16 +22,21 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
return; // 모듈 미사용 페이지
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 12;
|
||||
|
||||
// DOM Elements
|
||||
const searchInput = document.getElementById('apiSearch');
|
||||
const menuTitles = document.querySelectorAll('.s2-category-tab');
|
||||
const apiCardGrid = document.getElementById('apiCardGrid');
|
||||
const loadingState = document.getElementById('loadingState');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
const paginationEl = document.getElementById('apiPagination');
|
||||
|
||||
let currentFilter = ''; // Empty string means "all"
|
||||
let currentServiceName = '전체';
|
||||
let allApis = [];
|
||||
let allApis = []; // 현재 카테고리 조회 결과 전체
|
||||
let filteredApis = []; // allApis 에 검색어까지 적용한 결과(페이징 대상)
|
||||
let currentPage = 1;
|
||||
let selectedApis = new Set();
|
||||
|
||||
// Restore selected APIs from session (fragment 인라인 주입)
|
||||
@@ -44,8 +51,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
function loadApis(groupId) {
|
||||
loadingState.style.display = 'block';
|
||||
emptyState.style.display = 'none';
|
||||
|
||||
document.querySelectorAll('.s2-api-card').forEach(card => card.remove());
|
||||
clearCards();
|
||||
|
||||
const baseUrl = window.API_SELECTOR_LIST_URL || '/apis/for_request';
|
||||
let url = baseUrl;
|
||||
@@ -56,16 +62,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
fetch(url).then(response => response.json()).then(apis => {
|
||||
allApis = apis;
|
||||
loadingState.style.display = 'none';
|
||||
|
||||
if (apis.length === 0) {
|
||||
emptyState.style.display = 'block';
|
||||
document.getElementById('apiResultCount').textContent = '0';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('apiResultCount').textContent = apis.length;
|
||||
renderApiCards(apis);
|
||||
updateSelectAllUI();
|
||||
applySearch();
|
||||
}).catch(error => {
|
||||
console.error('Failed to load APIs:', error);
|
||||
loadingState.style.display = 'none';
|
||||
@@ -73,10 +70,49 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
emptyState.querySelector('p').textContent = '다시 시도해주세요.';
|
||||
emptyState.style.display = 'block';
|
||||
document.getElementById('apiResultCount').textContent = '0';
|
||||
renderPagination(0);
|
||||
});
|
||||
}
|
||||
|
||||
// Render API cards
|
||||
// 렌더된 카드만 제거(로딩/빈 상태 엘리먼트는 유지)
|
||||
function clearCards() {
|
||||
document.querySelectorAll('.s2-api-card').forEach(card => card.remove());
|
||||
}
|
||||
|
||||
// 검색어 기준으로 allApis → filteredApis 재계산 후 1페이지부터 렌더
|
||||
function applySearch() {
|
||||
const term = searchInput ? searchInput.value.toLowerCase().trim() : '';
|
||||
filteredApis = !term ? allApis : allApis.filter(function(api) {
|
||||
const name = (api.apiName || '').toLowerCase();
|
||||
const desc = (api.apiSimpleDescription || '').toLowerCase();
|
||||
return name.includes(term) || desc.includes(term);
|
||||
});
|
||||
goToPage(1);
|
||||
}
|
||||
|
||||
// 지정 페이지로 이동 — 해당 페이지분만 렌더(재조회 없음)
|
||||
function goToPage(page) {
|
||||
const totalPages = Math.max(1, Math.ceil(filteredApis.length / PAGE_SIZE));
|
||||
currentPage = Math.min(Math.max(1, page), totalPages);
|
||||
|
||||
clearCards();
|
||||
document.getElementById('apiResultCount').textContent = filteredApis.length;
|
||||
|
||||
if (filteredApis.length === 0) {
|
||||
emptyState.style.display = 'block';
|
||||
renderPagination(0);
|
||||
updateSelectAllUI();
|
||||
return;
|
||||
}
|
||||
emptyState.style.display = 'none';
|
||||
|
||||
const start = (currentPage - 1) * PAGE_SIZE;
|
||||
renderApiCards(filteredApis.slice(start, start + PAGE_SIZE));
|
||||
renderPagination(filteredApis.length);
|
||||
updateSelectAllUI();
|
||||
}
|
||||
|
||||
// Render API cards (현재 페이지분)
|
||||
function renderApiCards(apis) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
@@ -207,24 +243,25 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
updateSelectAllCheckboxState();
|
||||
}
|
||||
|
||||
// Update select all checkbox state based on visible cards
|
||||
// Update select all checkbox state — 현재 페이지가 아닌 필터된 전체 목록 기준(페이징 무관)
|
||||
function updateSelectAllCheckboxState() {
|
||||
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
|
||||
const visibleCards = Array.from(document.querySelectorAll('.s2-api-card')).filter(card => card.style.display !== 'none');
|
||||
if (!selectAllCheckbox) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (visibleCards.length === 0) {
|
||||
if (filteredApis.length === 0) {
|
||||
selectAllCheckbox.checked = false;
|
||||
selectAllCheckbox.indeterminate = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleCheckboxes = visibleCards.map(card => card.querySelector('.s2-api-checkbox'));
|
||||
const checkedCount = visibleCheckboxes.filter(cb => cb.checked).length;
|
||||
const checkedCount = filteredApis.filter(api => selectedApis.has(api.apiId)).length;
|
||||
|
||||
if (checkedCount === 0) {
|
||||
selectAllCheckbox.checked = false;
|
||||
selectAllCheckbox.indeterminate = false;
|
||||
} else if (checkedCount === visibleCheckboxes.length) {
|
||||
} else if (checkedCount === filteredApis.length) {
|
||||
selectAllCheckbox.checked = true;
|
||||
selectAllCheckbox.indeterminate = false;
|
||||
} else {
|
||||
@@ -233,7 +270,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
}
|
||||
|
||||
// Update modal selected APIs list — selectedApis Set 기준 (패치 2)
|
||||
// Update modal selected APIs list — selectedApis Set 기준(패치 2), 이름은 allApis 우선 조회(패치 4)
|
||||
function updateModalList() {
|
||||
const modalSelectedList = document.getElementById('modalSelectedList');
|
||||
modalSelectedList.innerHTML = '';
|
||||
@@ -244,8 +281,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
|
||||
selectedApis.forEach(function(apiId) {
|
||||
const apiData = allApis.find(a => a.apiId === apiId);
|
||||
const card = document.querySelector('.s2-api-card[data-api-id="' + apiId + '"]');
|
||||
const apiName = card ? card.querySelector('.s2-api-card-title').textContent : apiId;
|
||||
const apiName = apiData ? apiData.apiName
|
||||
: (card ? card.querySelector('.s2-api-card-title').textContent : apiId);
|
||||
|
||||
const apiPill = document.createElement('div');
|
||||
apiPill.className = 's2-api-pill';
|
||||
@@ -270,28 +309,69 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
}
|
||||
|
||||
// Search functionality
|
||||
// Pagination 컨트롤 렌더 — fragment/pagination.html 과 동일 마크업/클래스 재사용(전역 _pagination.scss 적용)
|
||||
function renderPagination(totalItems) {
|
||||
if (!paginationEl) {
|
||||
return;
|
||||
}
|
||||
paginationEl.innerHTML = '';
|
||||
|
||||
const totalPages = Math.ceil(totalItems / PAGE_SIZE);
|
||||
if (totalPages <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ICON_FIRST = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M0 19V5H2.30769V19H0ZM15 19L4.61538 12L15 5V19Z" fill="currentColor"/><path d="M24 19L15 12L24 5V19Z" fill="currentColor"/></svg><span class="blind">처음 페이지</span>';
|
||||
const ICON_PREV = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M16 5L16 19L5 12L16 5Z" fill="currentColor"/></svg><span class="blind">이전 페이지</span>';
|
||||
const ICON_NEXT = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8 19V5L19 12L8 19Z" fill="currentColor"/></svg><span class="blind">다음 페이지</span>';
|
||||
const ICON_LAST = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M24 5L24 19L21.6923 19L21.6923 5L24 5ZM9 5L19.3846 12L9 19L9 5Z" fill="currentColor"/><path d="M1.22392e-06 5L9 12L0 19L1.22392e-06 5Z" fill="currentColor"/></svg><span class="blind">마지막 페이지</span>';
|
||||
|
||||
function navLink(cls, iconHtml, targetPage, disabled) {
|
||||
const a = document.createElement('a');
|
||||
a.href = '#';
|
||||
a.className = cls + (disabled ? ' disabled' : '');
|
||||
a.innerHTML = iconHtml;
|
||||
if (!disabled) {
|
||||
a.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
goToPage(targetPage);
|
||||
});
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
function numLink(p) {
|
||||
const a = document.createElement('a');
|
||||
a.href = '#';
|
||||
const isCurrent = p === currentPage;
|
||||
a.className = 'page-num' + (isCurrent ? ' page-current' : '');
|
||||
a.textContent = String(p);
|
||||
if (!isCurrent) {
|
||||
a.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
goToPage(p);
|
||||
});
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
paginationEl.appendChild(navLink('page-first', ICON_FIRST, 1, currentPage === 1));
|
||||
paginationEl.appendChild(navLink('page-prev', ICON_PREV, currentPage - 1, currentPage === 1));
|
||||
|
||||
const windowStart = Math.max(1, Math.min(currentPage - 2, totalPages - 4));
|
||||
const windowEnd = Math.min(totalPages, windowStart + 4);
|
||||
for (let p = Math.max(1, windowStart); p <= windowEnd; p++) {
|
||||
paginationEl.appendChild(numLink(p));
|
||||
}
|
||||
|
||||
paginationEl.appendChild(navLink('page-next', ICON_NEXT, currentPage + 1, currentPage === totalPages));
|
||||
paginationEl.appendChild(navLink('page-last', ICON_LAST, totalPages, currentPage === totalPages));
|
||||
}
|
||||
|
||||
// Search functionality — 클라이언트 필터(재조회 없음), 필터 변경 시 1페이지로 리셋
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', function() {
|
||||
const searchTerm = this.value.toLowerCase();
|
||||
|
||||
const apiCards = document.querySelectorAll('.s2-api-card');
|
||||
let visibleCount = 0;
|
||||
apiCards.forEach(function(card) {
|
||||
const apiName = card.getAttribute('data-name');
|
||||
const apiDesc = card.getAttribute('data-desc');
|
||||
const matchesSearch = apiName.includes(searchTerm) || apiDesc.includes(searchTerm);
|
||||
|
||||
if (matchesSearch) {
|
||||
card.style.display = '';
|
||||
visibleCount++;
|
||||
} else {
|
||||
card.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('apiResultCount').textContent = visibleCount;
|
||||
updateSelectAllCheckboxState();
|
||||
applySearch();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -307,11 +387,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
currentFilter = groupId;
|
||||
currentServiceName = this.textContent.trim();
|
||||
|
||||
loadApis(groupId);
|
||||
|
||||
if (searchInput) {
|
||||
searchInput.value = '';
|
||||
}
|
||||
loadApis(groupId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -398,18 +477,26 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
});
|
||||
|
||||
// Select All checkbox event
|
||||
// Select All checkbox event — 현재 페이지가 아닌 필터된 전체 목록 대상(페이징 무관)
|
||||
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
|
||||
if (selectAllCheckbox) {
|
||||
selectAllCheckbox.addEventListener('change', function() {
|
||||
const isChecked = this.checked;
|
||||
const visibleCards = Array.from(document.querySelectorAll('.s2-api-card')).filter(card => card.style.display !== 'none');
|
||||
|
||||
visibleCards.forEach(function(card) {
|
||||
filteredApis.forEach(function(api) {
|
||||
if (isChecked) {
|
||||
selectedApis.add(api.apiId);
|
||||
} else {
|
||||
selectedApis.delete(api.apiId);
|
||||
}
|
||||
});
|
||||
|
||||
// 현재 페이지에 실제 렌더된 카드만 체크 상태 동기화
|
||||
document.querySelectorAll('.s2-api-card').forEach(function(card) {
|
||||
const checkbox = card.querySelector('.s2-api-checkbox');
|
||||
if (checkbox) {
|
||||
checkbox.checked = isChecked;
|
||||
updateCardSelection(checkbox);
|
||||
card.classList.toggle('selected', isChecked);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -137,7 +137,8 @@
|
||||
align-items: flex-end;
|
||||
flex-wrap: nowrap;
|
||||
|
||||
// width 가 고정(114px)이라 flex 축소가 걸리면 가로만 눌려 비율이 깨진다
|
||||
// 로고 파일(PTL_PROPERTY brand.logo.header.path)마다 원본 비율이 달라 width는 auto로 두고
|
||||
// height만 고정한다. flex 축소가 걸리면 그 auto width가 눌릴 수 있어 shrink는 막아둔다.
|
||||
img { flex-shrink: 0; }
|
||||
|
||||
.mobile-logo-link {
|
||||
@@ -245,7 +246,7 @@
|
||||
|
||||
img {
|
||||
height: 32px;
|
||||
width: 114px;
|
||||
width: auto;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,101 +25,348 @@ $service-card-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgb
|
||||
// DJBank 개발자포탈 소개 (Figma 352:15 기반)
|
||||
// =============================================================================
|
||||
|
||||
$intro-navy: #0B2A5B;
|
||||
|
||||
.service-intro {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #fff;
|
||||
color: #000;
|
||||
color: $service-text-dark;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Intro page content sections (callout / eyebrow+title / card grids / steps)
|
||||
// -----------------------------------------------------------------------------
|
||||
.intro-callout {
|
||||
background: $intro-navy;
|
||||
border-radius: 16px;
|
||||
padding: 34px 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
gap: 30px;
|
||||
align-items: center;
|
||||
box-shadow: 0 14px 34px rgba(11, 42, 91, .18);
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 25px;
|
||||
line-height: 1.4;
|
||||
letter-spacing: -0.01em;
|
||||
&__icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
flex: none;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, .14);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
&__lead {
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 11px;
|
||||
}
|
||||
|
||||
li {
|
||||
position: relative;
|
||||
padding-left: 15px;
|
||||
color: #C2D4EA;
|
||||
font-size: 15.5px;
|
||||
line-height: 1.6;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 10px;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: $service-icon-cyan;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
&__spacer {
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
&__section-title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&__section-body {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
line-height: 1.7;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
|
||||
&+p {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__list {
|
||||
margin: 0;
|
||||
padding-left: 22px;
|
||||
list-style: disc;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
line-height: 1.7;
|
||||
|
||||
li+li {
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.service-intro {
|
||||
padding: 16px;
|
||||
gap: 16px;
|
||||
.intro-section {
|
||||
&__eyebrow {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 2.4px;
|
||||
color: $service-primary-blue;
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 22px;
|
||||
font-size: 30px;
|
||||
font-weight: 900;
|
||||
letter-spacing: -1px;
|
||||
color: $intro-navy;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__lead {
|
||||
font-size: 17px;
|
||||
margin-top: 14px;
|
||||
font-size: 16px;
|
||||
color: $service-text-gray;
|
||||
line-height: 1.85;
|
||||
max-width: 830px;
|
||||
}
|
||||
|
||||
&__section-title {
|
||||
font-size: 17px;
|
||||
&+& {
|
||||
margin-top: 76px;
|
||||
}
|
||||
}
|
||||
|
||||
.intro-who {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 18px;
|
||||
margin-top: 28px;
|
||||
|
||||
&__item {
|
||||
border: 1px solid $service-card-border;
|
||||
border-radius: 14px;
|
||||
padding: 22px 20px;
|
||||
text-align: center;
|
||||
background: #fff;
|
||||
|
||||
p {
|
||||
margin-top: 6px;
|
||||
font-size: 13.6px;
|
||||
color: $service-text-gray;
|
||||
line-height: 1.65;
|
||||
}
|
||||
}
|
||||
|
||||
&__desc,
|
||||
&__section-body,
|
||||
&__list {
|
||||
&__title {
|
||||
margin-top: 12px;
|
||||
font-size: 15.5px;
|
||||
font-weight: 800;
|
||||
color: $intro-navy;
|
||||
}
|
||||
}
|
||||
|
||||
.intro-grid3 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.intro-card {
|
||||
border: 1px solid $service-card-border;
|
||||
border-radius: 16px;
|
||||
padding: 26px;
|
||||
background: #fff;
|
||||
box-shadow: $service-card-shadow;
|
||||
|
||||
&__icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 11px;
|
||||
background: #EFF6FD;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: $intro-navy;
|
||||
letter-spacing: -.5px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 9px;
|
||||
font-size: 14.6px;
|
||||
color: $service-text-gray;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: 'Fira Code', monospace;
|
||||
font-size: 13px;
|
||||
background: #EFF4FA;
|
||||
color: $service-primary-blue;
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
&__tag {
|
||||
display: inline-block;
|
||||
margin-top: 14px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: $service-primary-blue;
|
||||
background: #EEF6FD;
|
||||
border-radius: 6px;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.intro-diagram {
|
||||
margin-top: 28px;
|
||||
border: 1px solid $service-card-border;
|
||||
border-radius: 16px;
|
||||
padding: 30px 24px;
|
||||
background: #FAFCFF;
|
||||
overflow-x: auto;
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 700px;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.intro-steps {
|
||||
margin-top: 34px;
|
||||
position: relative;
|
||||
padding-left: 38px;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
top: 14px;
|
||||
bottom: 14px;
|
||||
width: 2px;
|
||||
background: #D8E5F3;
|
||||
}
|
||||
}
|
||||
|
||||
.intro-step {
|
||||
display: flex;
|
||||
gap: 22px;
|
||||
margin-bottom: 18px;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -38px;
|
||||
top: 34px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: $service-primary-blue;
|
||||
border: 3px solid #fff;
|
||||
box-shadow: 0 0 0 3px #D9E7F7;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: 88px;
|
||||
height: 82px;
|
||||
flex: none;
|
||||
border: 1px solid $service-card-border;
|
||||
border-radius: 14px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
&__body {
|
||||
flex: 1;
|
||||
border: 1px solid $service-card-border;
|
||||
border-radius: 14px;
|
||||
padding: 19px 26px;
|
||||
background: #fff;
|
||||
|
||||
p {
|
||||
margin-top: 5px;
|
||||
font-size: 14.4px;
|
||||
color: $service-text-gray;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
color: $intro-navy;
|
||||
|
||||
em {
|
||||
font-style: normal;
|
||||
color: $service-primary-blue;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .6px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.intro-cta {
|
||||
margin-top: 64px;
|
||||
border-radius: 18px;
|
||||
padding: 38px 44px;
|
||||
background: linear-gradient(100deg, #EAF4FD, #DFEDFB);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
border: 1px solid #D5E6F7;
|
||||
|
||||
&__body {
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
color: $intro-navy;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 6px;
|
||||
font-size: 15px;
|
||||
color: $service-text-gray;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-action-primary {
|
||||
margin-left: auto;
|
||||
flex: none;
|
||||
font-size: 15.5px;
|
||||
padding: 15px 30px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.intro-who,
|
||||
.intro-grid3 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.intro-cta {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
.btn-action-primary {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.intro-who,
|
||||
.intro-grid3 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.intro-callout {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.intro-section__title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.intro-step {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// Service Common Sidebar & Main Layout
|
||||
|
||||
@@ -756,7 +756,7 @@ $wh-bg-soft: #f9f9f9;
|
||||
|
||||
.secret-action-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
|
||||
@@ -767,23 +767,23 @@ $wh-bg-soft: #f9f9f9;
|
||||
|
||||
.secret-box {
|
||||
flex: 1;
|
||||
max-width: 250px;
|
||||
height: 48px;
|
||||
min-width: 0;
|
||||
min-height: 48px;
|
||||
background-color: #efefef;
|
||||
border-radius: 10px;
|
||||
padding: 0 20px;
|
||||
padding: 12px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: #4e5968;
|
||||
font-weight: 500;
|
||||
box-sizing: border-box;
|
||||
letter-spacing: 1px;
|
||||
word-break: break-all;
|
||||
white-space: normal;
|
||||
border: 1px solid #DFDFDF;
|
||||
|
||||
@media (max-width: 576px) {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<span class="service-hero__badge-text">OPEN API 목록</span>
|
||||
</div>
|
||||
<h1 class="service-hero__title">OPEN API</h1>
|
||||
<p class="service-hero__desc">비즈니스 확장을 위한 DJBank의 핵심 API 인프라를 제공합니다.<br>원하는 API를 선택하여 상세 가이드를 확인하고, 테스트 키를
|
||||
<p class="service-hero__desc">비즈니스 확장을 위한 [[${brandName}]]의 핵심 API 인프라를 제공합니다.<br>원하는 API를 선택하여 상세 가이드를 확인하고, 테스트 키를
|
||||
발급받아 지금 바로 개발을 시작해 보세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<div class="inner i_cs h_inner8 h_inner10">
|
||||
<div class="swagger_title m-only">
|
||||
<p class="title">API 테스트 베드</p>
|
||||
<p class="text">DJBank API Portal은 Swagger를 이용해 API를 테스트 할 수 있습니다.</p>
|
||||
<p class="text">[[${brandName}]] API Portal은 Swagger를 이용해 API를 테스트 할 수 있습니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="custom_select select_w h_inp" id="apiList">
|
||||
|
||||
@@ -96,7 +96,7 @@
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
||||
</svg>
|
||||
제주은행(DJBank) 보안 인증
|
||||
제주은행([[${brandName}]]) 보안 인증
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<span class="service-hero__badge-text">Q&A 게시판</span>
|
||||
</div>
|
||||
<h1 class="service-hero__title">Q&A</h1>
|
||||
<p class="service-hero__desc">DJBank 오픈 API 이용 중 발생한 의문점이나 불편 사항을 보내주세요<br>접수해주신 문의 사항은 담당자 확인 후 빠르게 안내해
|
||||
<p class="service-hero__desc">[[${brandName}]] 오픈 API 이용 중 발생한 의문점이나 불편 사항을 보내주세요<br>접수해주신 문의 사항은 담당자 확인 후 빠르게 안내해
|
||||
드리겠습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<span class="service-hero__badge-text">Q&A 게시판</span>
|
||||
</div>
|
||||
<h1 class="service-hero__title">Q&A</h1>
|
||||
<p class="service-hero__desc">DJBank 오픈 API 이용 중 발생한 의문점이나 불편 사항을 보내주세요<br>접수해주신 문의 사항은 담당자 확인 후 빠르게 안내해
|
||||
<p class="service-hero__desc">[[${brandName}]] 오픈 API 이용 중 발생한 의문점이나 불편 사항을 보내주세요<br>접수해주신 문의 사항은 담당자 확인 후 빠르게 안내해
|
||||
드리겠습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<span class="service-hero__badge-text">Q&A 게시판</span>
|
||||
</div>
|
||||
<h1 class="service-hero__title">Q&A</h1>
|
||||
<p class="service-hero__desc">DJBank 오픈 API 이용 중 발생한 의문점이나 불편 사항을 보내주세요<br>접수해주신 문의 사항은 담당자 확인 후 빠르게 안내해
|
||||
<p class="service-hero__desc">[[${brandName}]] 오픈 API 이용 중 발생한 의문점이나 불편 사항을 보내주세요<br>접수해주신 문의 사항은 담당자 확인 후 빠르게 안내해
|
||||
드리겠습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<span class="service-hero__badge-text">고객지원</span>
|
||||
</div>
|
||||
<h1 class="service-hero__title">공지사항</h1>
|
||||
<p class="service-hero__desc">DJBank 오픈 API 포털의 주요 안내 및 업데이트 소식을 전해드립니다.<br>원활한 서비스 연동을 위해 변경 사항을 주기적으로 확인해
|
||||
<p class="service-hero__desc">[[${brandName}]] 오픈 API 포털의 주요 안내 및 업데이트 소식을 전해드립니다.<br>원활한 서비스 연동을 위해 변경 사항을 주기적으로 확인해
|
||||
주시기 바랍니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<span class="service-hero__badge-text">고객지원</span>
|
||||
</div>
|
||||
<h1 class="service-hero__title">공지사항</h1>
|
||||
<p class="service-hero__desc">DJBank 오픈 API 포털의 주요 안내 및 업데이트 소식을 전해드립니다.<br>원활한 서비스 연동을 위해 변경 사항을 주기적으로 확인해
|
||||
<p class="service-hero__desc">[[${brandName}]] 오픈 API 포털의 주요 안내 및 업데이트 소식을 전해드립니다.<br>원활한 서비스 연동을 위해 변경 사항을 주기적으로 확인해
|
||||
주시기 바랍니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<span class="service-hero__badge-text">피드백/개선요청</span>
|
||||
</div>
|
||||
<h1 class="service-hero__title">피드백/개선요청</h1>
|
||||
<p class="service-hero__desc">DJBank 오픈 API 이용 중 발생한 피드백이나 개선요청을 보내주세요<br>작성해주신 내용은 담당자 확인 후 적극 반영하겠습니다.</p>
|
||||
<p class="service-hero__desc">[[${brandName}]] 오픈 API 이용 중 발생한 피드백이나 개선요청을 보내주세요<br>작성해주신 내용은 담당자 확인 후 적극 반영하겠습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -89,7 +89,7 @@
|
||||
<div class="board-header">
|
||||
<h2 class="board-title">피드백 / 개선 요청
|
||||
</h2>
|
||||
<p class="board-desc">DJ Bank은 온라인 비즈니스 혁신을 위한 피드백/개선요청을
|
||||
<p class="board-desc">[[${brandName}]][[${brandNameJosaEun}]] 온라인 비즈니스 혁신을 위한 피드백/개선요청을
|
||||
환영합니다.</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
</div>
|
||||
<h2 class="login-title">로그인</h2>
|
||||
</div>
|
||||
<p class="login-message-sub">DJ Bank에 오신걸 환영합니다</p>
|
||||
<p class="login-message-sub">[[${brandName}]]에 오신걸 환영합니다</p>
|
||||
</div>
|
||||
|
||||
<!-- Alert Messages -->
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<div class="hero-text-content">
|
||||
<div class="hero-text">
|
||||
<p class="hero-subtitle">세상의 모든 서비스</p>
|
||||
<h2 class="hero-title">DJBank API가<br>함께 합니다.</h2>
|
||||
<h2 class="hero-title">[[${brandName}]] API가<br>함께 합니다.</h2>
|
||||
</div>
|
||||
<a th:href="@{/service/guide}" class="btn-hero-signup">회원 가입 안내 <i class="bi bi-chevron-right"></i></a>
|
||||
</div>
|
||||
@@ -45,7 +45,7 @@
|
||||
<div class="hero-text-content">
|
||||
<div class="hero-text">
|
||||
<p class="hero-subtitle">문서는 상세하게, 연동은 확실하게</p>
|
||||
<h2 class="hero-title">준비된 DJBank API로 <br>완벽한 서비스를 성공하세요.</h2>
|
||||
<h2 class="hero-title">준비된 [[${brandName}]] API로 <br>완벽한 서비스를 성공하세요.</h2>
|
||||
</div>
|
||||
<a th:href="@{/service/oauth2-guide}" class="btn-hero-signup">개발 가이드 보기 <i
|
||||
class="bi bi-chevron-right"></i></a>
|
||||
@@ -234,7 +234,7 @@
|
||||
<span th:if="${service.groupDesc != null and !#strings.isEmpty(service.groupDesc)}"
|
||||
th:text="${service.groupDesc}">서비스 설명</span>
|
||||
<span th:unless="${service.groupDesc != null and !#strings.isEmpty(service.groupDesc)}">
|
||||
DJBank API 서비스를 이용해보세요.
|
||||
[[${brandName}]] API 서비스를 이용해보세요.
|
||||
</span>
|
||||
</p>
|
||||
<div class="card-illustration">
|
||||
@@ -286,13 +286,13 @@
|
||||
<div class="info-content">
|
||||
<h2 class="info-title">
|
||||
<span class="title-sub">차별화된 API 서비스</span>
|
||||
<span class="title-highlight">DJBank API Portal</span>
|
||||
<span class="title-highlight">[[${brandName}]] API Portal</span>
|
||||
</h2>
|
||||
<p class="info-description">
|
||||
DJBank API Portal은 기업이 혁신적인 금융 서비스를 쉽고 신속하게 개발하고 구현할 수 있도록 엄선된 API 명세와 직관적인 샌드박스 테스트 환경을 무상으로 지원합니다.
|
||||
[[${brandName}]] API Portal은 기업이 혁신적인 금융 서비스를 쉽고 신속하게 개발하고 구현할 수 있도록 엄선된 API 명세와 직관적인 샌드박스 테스트 환경을 무상으로 지원합니다.
|
||||
</p>
|
||||
<div class="action-buttons">
|
||||
<a th:href="@{/service/intro}" class="action-btn btn-secondary">처음 만나는 DJBank API</a>
|
||||
<a th:href="@{/service/intro}" class="action-btn btn-secondary">처음 만나는 [[${brandName}]] API</a>
|
||||
<a th:href="@{/partnership}" class="action-btn btn-primary">피드백 / 개선요청 <i
|
||||
class="bi bi-patch-question"></i></a>
|
||||
</div>
|
||||
@@ -323,7 +323,7 @@
|
||||
<div class="support-header">
|
||||
<h2 class="support-title">
|
||||
<span class="title-regular">비즈니스의 시작,</span><br>
|
||||
<span class="title-bold">DJBank 오픈 API가 함께 하겠습니다.</span>
|
||||
<span class="title-bold">[[${brandName}]] 오픈 API가 함께 하겠습니다.</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
@@ -339,7 +339,7 @@
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<h3>공지사항</h3>
|
||||
<p>DJBank API의 다양한 새로운 소식을 가장 먼저 전해드립니다.</p>
|
||||
<p>[[${brandName}]] API의 다양한 새로운 소식을 가장 먼저 전해드립니다.</p>
|
||||
</div>
|
||||
<div class="card-arrow">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor"
|
||||
@@ -473,7 +473,7 @@
|
||||
<div class="stats-header">
|
||||
<h2 class="stats-title">
|
||||
<span class="title-top">우리 곁의 수많은 서비스들이</span><br>
|
||||
<span class="title-highlight">DJBank 오픈 API</span>와 함께하고 있습니다.
|
||||
<span class="title-highlight">[[${brandName}]] 오픈 API</span>와 함께하고 있습니다.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
@@ -548,7 +548,7 @@
|
||||
<div class="cta-background"></div>
|
||||
<div class="container">
|
||||
<div class="cta-content">
|
||||
<h2 class="cta-title">DJBank API를 지금 바로 만나보세요.</h2>
|
||||
<h2 class="cta-title">[[${brandName}]] API를 지금 바로 만나보세요.</h2>
|
||||
<a th:href="@{/signup}" class="btn-signup-cta">회원가입하기</a>
|
||||
</div>
|
||||
<img class="ctaImg right" th:src="@{/img/avatar1.svg}" ali="아바타1">
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</svg>
|
||||
<h2 class="signup-title">회원가입</h2>
|
||||
</div>
|
||||
<p class="signup-message">DJBank API Portal 사용을 위해 회원 가입해 주세요.</p>
|
||||
<p class="signup-message">[[${brandName}]] API Portal 사용을 위해 회원 가입해 주세요.</p>
|
||||
</div>
|
||||
|
||||
<!-- Signup Cards -->
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
</p>
|
||||
<p class="info-text" style="margin-top: 16px;">
|
||||
<strong th:text="${orgName}"></strong>에서
|
||||
DJBank API Portal 법인회원으로 초대하였습니다.
|
||||
[[${brandName}]] API Portal 법인회원으로 초대하였습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
<span th:text="${userName}"></span>님, 법인회원 초대가 도착했습니다.
|
||||
</strong>
|
||||
<p>
|
||||
<strong><span th:text="${orgName}"></span></strong>에서 DJBank API Portal 법인회원으로 초대하였습니다.<br>
|
||||
<strong><span th:text="${orgName}"></span></strong>에서 [[${brandName}]] API Portal 법인회원으로 초대하였습니다.<br>
|
||||
초대를 수락하시면 법인회원으로 전환됩니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<span class="service-hero__badge-text">회원가입 소개</span>
|
||||
</div>
|
||||
<h1 class="service-hero__title">회원 가입 안내</h1>
|
||||
<p class="service-hero__desc">DJ Bank API 개발자 포털에 방문해 주셔서 감사합니다.<br>DJBank API 사용을 위해서는 다음과 같은
|
||||
<p class="service-hero__desc">[[${brandName}]] API 개발자 포털에 방문해 주셔서 감사합니다.<br>[[${brandName}]] API 사용을 위해서는 다음과 같은
|
||||
이용절차로 진행하여야 합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,9 +5,235 @@
|
||||
<body>
|
||||
|
||||
<th:block layout:fragment="contentFragment">
|
||||
<section class="service-intro">
|
||||
<h1 class="service-intro__title">DJBank API Portal 소개</h1>
|
||||
<div class="service-intro">
|
||||
|
||||
<!-- Hero -->
|
||||
<section class="service-hero">
|
||||
<div class="service-hero__inner">
|
||||
<div class="service-hero__icon-wrapper">
|
||||
<svg width="230" height="167" viewBox="0 0 300 200" fill="none" aria-hidden="true">
|
||||
<ellipse cx="150" cy="178" rx="98" ry="14" fill="#0B2A5B" opacity=".88"/>
|
||||
<path d="M44 96h212" stroke="#0049B4" stroke-width="6" stroke-linecap="round"/>
|
||||
<path d="M60 96v58h180V96" stroke="#0049B4" stroke-width="6" stroke-linejoin="round"/>
|
||||
<path d="M40 96 150 38l110 58" stroke="#0B2A5B" stroke-width="7" stroke-linejoin="round" fill="#fff"/>
|
||||
<rect x="84" y="106" width="14" height="40" rx="6" fill="#00ACDD"/>
|
||||
<rect x="118" y="106" width="14" height="40" rx="6" fill="#00ACDD"/>
|
||||
<rect x="168" y="106" width="14" height="40" rx="6" fill="#00ACDD"/>
|
||||
<rect x="202" y="106" width="14" height="40" rx="6" fill="#00ACDD"/>
|
||||
<circle cx="150" cy="74" r="13" fill="#F08A24"/>
|
||||
<path d="M144 74h12M150 68v12" stroke="#fff" stroke-width="3" stroke-linecap="round"/>
|
||||
<path d="M22 140h16m-8-8v16" stroke="#B9D3EC" stroke-width="5" stroke-linecap="round"/>
|
||||
<path d="M262 140h16m-8-8v16" stroke="#B9D3EC" stroke-width="5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="service-hero__content">
|
||||
<div class="service-hero__badge">
|
||||
<span class="service-hero__badge-dot"></span>
|
||||
<span class="service-hero__badge-text">서비스 소개</span>
|
||||
</div>
|
||||
<h1 class="service-hero__title">[[${brandName}]]의 금융을<br>API로 연결합니다</h1>
|
||||
<p class="service-hero__desc">
|
||||
1969년 제주에서 시작해 신한금융그룹과 함께 성장해 온 [[${brandName}]][[${brandNameJosaGa}]]<br>
|
||||
인증·조회·이체·기업여신 서비스를 표준 오픈 API로 개방합니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="container service-main">
|
||||
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('intro')}"></th:block>
|
||||
|
||||
<section class="service-content">
|
||||
|
||||
<!-- Callout -->
|
||||
<div class="intro-callout">
|
||||
<div class="intro-callout__icon">
|
||||
<svg width="26" height="26" viewBox="0 0 26 26" fill="none"><path d="m6 13.4 5 5 9.5-11" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</div>
|
||||
<ul>
|
||||
<li>[[${brandName}]] API 포탈은 <strong>핀테크·법인·솔루션 사업자</strong>가 [[${brandName}]] 금융 서비스를 연동하는 <strong>공식 파트너 채널</strong>입니다.</li>
|
||||
<li>API 명세·샌드박스·이용 신청·호출 이력을 <strong>한 곳에서</strong> 제공하며, 모든 호출은 <strong>OAuth2 기반 인증</strong>으로 보호됩니다.</li>
|
||||
<li>이용은 <strong>회원가입 → 심사·승인 → 앱 등록 → 테스트 → 운영 전환</strong> 순으로 진행됩니다.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- ABOUT -->
|
||||
<section class="intro-section" aria-labelledby="intro-about-title">
|
||||
<span class="intro-section__eyebrow">ABOUT PORTAL</span>
|
||||
<h2 class="intro-section__title" id="intro-about-title">[[${brandName}]] 오픈 API 포탈이란</h2>
|
||||
<p class="intro-section__lead">
|
||||
1969년 설립된 제주은행은 반세기 넘게 지역 경제의 중추 역할을 해왔고, 신한금융지주회사의 자회사 편입 이후
|
||||
디지털 전환에 속도를 내고 있습니다. 은행의 비전인 <strong>“제주를 더 가깝고, 더 편리하게 — 당신의 설렘을 담은 은행”</strong>은
|
||||
창구를 넘어 고객이 이미 사용하는 서비스 안으로 금융을 옮기는 일에서 시작합니다.<br><br>
|
||||
API 포탈은 그 실행 도구입니다. 계좌 조회와 이체 같은 기본 뱅킹부터 기업여신·수납·알림까지,
|
||||
내부에서만 쓰이던 금융 기능을 표준 REST API와 웹훅으로 정리해 외부 파트너에게 개방합니다.
|
||||
문서·샌드박스·키 관리·모니터링을 하나의 화면에서 제공하므로, 별도 협의 없이도 연동 설계를 먼저 시작할 수 있습니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- WHO -->
|
||||
<section class="intro-section" aria-labelledby="intro-who-title">
|
||||
<span class="intro-section__eyebrow">FOR WHOM</span>
|
||||
<h2 class="intro-section__title" id="intro-who-title">이런 분들이 이용합니다</h2>
|
||||
<div class="intro-who">
|
||||
<div class="intro-who__item">
|
||||
<svg width="34" height="34" viewBox="0 0 24 24" fill="none"><rect x="3" y="5" width="18" height="14" rx="3" stroke="#0049B4" stroke-width="1.8"/><path d="M8 12h8M8 15.5h5" stroke="#00ACDD" stroke-width="1.8" stroke-linecap="round"/></svg>
|
||||
<div class="intro-who__title">핀테크 기업</div>
|
||||
<p>결제·자산관리 서비스에 은행 계좌 기능을 탑재</p>
|
||||
</div>
|
||||
<div class="intro-who__item">
|
||||
<svg width="34" height="34" viewBox="0 0 24 24" fill="none"><path d="M4 20V8l8-4 8 4v12" stroke="#0049B4" stroke-width="1.8" stroke-linejoin="round"/><path d="M9.5 20v-5h5v5" stroke="#00ACDD" stroke-width="1.8" stroke-linejoin="round"/></svg>
|
||||
<div class="intro-who__title">법인 · 기업 고객</div>
|
||||
<p>자체 ERP·그룹웨어에서 자금 업무 자동화</p>
|
||||
</div>
|
||||
<div class="intro-who__item">
|
||||
<svg width="34" height="34" viewBox="0 0 24 24" fill="none"><path d="M9 6 4 12l5 6m6-12 5 6-5 6" stroke="#0049B4" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<div class="intro-who__title">ERP · 회계 솔루션사</div>
|
||||
<p>SaaS 제품에 임베디드 뱅킹 기능 제공</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SERVICES -->
|
||||
<section class="intro-section" aria-labelledby="intro-services-title">
|
||||
<span class="intro-section__eyebrow">API SERVICES</span>
|
||||
<h2 class="intro-section__title" id="intro-services-title">제공 서비스</h2>
|
||||
<p class="intro-section__lead">6개 도메인으로 구성되며, 서비스별로 이용 신청과 심사가 개별 진행됩니다.</p>
|
||||
<div class="intro-grid3">
|
||||
<div class="intro-card">
|
||||
<div class="intro-card__icon"><svg width="22" height="22" viewBox="0 0 22 22" fill="none"><rect x="4" y="9.5" width="14" height="9" rx="2.4" stroke="#0049B4" stroke-width="2"/><path d="M7.5 9.5V7a3.5 3.5 0 1 1 7 0v2.5" stroke="#0049B4" stroke-width="2"/></svg></div>
|
||||
<h3>인증 · 토큰</h3>
|
||||
<p>OAuth2 client_credentials로 access_token을 발급하고, 모든 호출에 Bearer 토큰을 사용합니다.</p>
|
||||
<span class="intro-card__tag">OAuth2</span>
|
||||
</div>
|
||||
<div class="intro-card">
|
||||
<div class="intro-card__icon"><svg width="22" height="22" viewBox="0 0 22 22" fill="none"><rect x="2.5" y="5" width="17" height="12" rx="2.6" stroke="#0049B4" stroke-width="2"/><path d="M2.5 9.5h17" stroke="#0049B4" stroke-width="2"/></svg></div>
|
||||
<h3>계좌 · 조회</h3>
|
||||
<p>실명확인, 계좌 개설, 잔액·거래내역 조회 등 기본 뱅킹 조회 기능을 제공합니다.</p>
|
||||
<span class="intro-card__tag">Account</span>
|
||||
</div>
|
||||
<div class="intro-card">
|
||||
<div class="intro-card__icon"><svg width="22" height="22" viewBox="0 0 22 22" fill="none"><path d="M4 7h11l-3-3m6 11H7l3 3" stroke="#0049B4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
|
||||
<h3>이체 · 자금</h3>
|
||||
<p>단건·대량이체, 급여이체, 예약이체와 이체 결과 조회를 지원합니다.</p>
|
||||
<span class="intro-card__tag">Transfer</span>
|
||||
</div>
|
||||
<div class="intro-card">
|
||||
<div class="intro-card__icon"><svg width="22" height="22" viewBox="0 0 22 22" fill="none"><path d="M3 18V9m5 9V4m5 14v-6m5 6V7" stroke="#0049B4" stroke-width="2.2" stroke-linecap="round"/></svg></div>
|
||||
<h3>기업여신</h3>
|
||||
<p>사전 한도 조회, 대출 신청·실행·상환, 매출채권 기반 여신 연계를 처리합니다.</p>
|
||||
<span class="intro-card__tag">Loan</span>
|
||||
</div>
|
||||
<div class="intro-card">
|
||||
<div class="intro-card__icon"><svg width="22" height="22" viewBox="0 0 22 22" fill="none"><circle cx="11" cy="11" r="7.5" stroke="#0049B4" stroke-width="2"/><path d="M11 6.5v5l3 2" stroke="#0049B4" stroke-width="2" stroke-linecap="round"/></svg></div>
|
||||
<h3>수납 · 외환</h3>
|
||||
<p>가상계좌 발급·입금 통지, 공과금 수납, 환율 조회 등 부가 금융 서비스입니다.</p>
|
||||
<span class="intro-card__tag">Billing / FX</span>
|
||||
</div>
|
||||
<div class="intro-card">
|
||||
<div class="intro-card__icon"><svg width="22" height="22" viewBox="0 0 22 22" fill="none"><path d="M11 3a6 6 0 0 1 6 6v4l2 3H4l2-3V9a6 6 0 0 1 5-6Z" stroke="#0049B4" stroke-width="2" stroke-linejoin="round"/><path d="M9 18.5a2.2 2.2 0 0 0 4 0" stroke="#0049B4" stroke-width="2" stroke-linecap="round"/></svg></div>
|
||||
<h3>웹훅 · 알림</h3>
|
||||
<p>입출금, 심사 결과, 상태 변경 이벤트를 등록된 URL로 실시간 전송합니다.</p>
|
||||
<span class="intro-card__tag">Webhook</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ARCHITECTURE -->
|
||||
<section class="intro-section" aria-labelledby="intro-arch-title">
|
||||
<span class="intro-section__eyebrow">ARCHITECTURE</span>
|
||||
<h2 class="intro-section__title" id="intro-arch-title">연동 구조</h2>
|
||||
<div class="intro-diagram">
|
||||
<svg width="100%" viewBox="0 0 950 250" fill="none">
|
||||
<rect x="8" y="48" width="200" height="152" rx="14" fill="#EDF9FE" stroke="#C6DEF5"/>
|
||||
<text x="108" y="38" text-anchor="middle" font-size="13" font-weight="700" fill="#0049B4">PARTNER</text>
|
||||
<text x="108" y="106" text-anchor="middle" font-size="16" font-weight="800" fill="#0B2A5B">파트너 시스템</text>
|
||||
<text x="108" y="134" text-anchor="middle" font-size="12.5" fill="#55688A">핀테크 앱 · ERP · 회계 SaaS</text>
|
||||
<text x="108" y="154" text-anchor="middle" font-size="12.5" fill="#55688A">법인 자체 시스템</text>
|
||||
|
||||
<path d="M214 124h96" stroke="#0049B4" stroke-width="2.4"/><path d="m306 118 10 6-10 6" fill="#0049B4"/>
|
||||
<text x="262" y="112" text-anchor="middle" font-size="11.5" font-weight="700" fill="#0049B4">HTTPS / OAuth2</text>
|
||||
|
||||
<rect x="322" y="26" width="286" height="196" rx="14" fill="#0B2A5B"/>
|
||||
<text x="465" y="56" text-anchor="middle" font-size="13" font-weight="700" fill="#4E9BE0">JEJU BANK API PORTAL</text>
|
||||
<rect x="346" y="74" width="118" height="42" rx="9" fill="rgba(255,255,255,.1)"/><text x="405" y="100" text-anchor="middle" font-size="12.5" font-weight="600" fill="#fff">인증 서버</text>
|
||||
<rect x="470" y="74" width="118" height="42" rx="9" fill="rgba(255,255,255,.1)"/><text x="529" y="100" text-anchor="middle" font-size="12.5" font-weight="600" fill="#fff">API Gateway</text>
|
||||
<rect x="346" y="126" width="118" height="42" rx="9" fill="rgba(255,255,255,.1)"/><text x="405" y="152" text-anchor="middle" font-size="12.5" font-weight="600" fill="#fff">유량 · IP 제어</text>
|
||||
<rect x="470" y="126" width="118" height="42" rx="9" fill="rgba(255,255,255,.1)"/><text x="529" y="152" text-anchor="middle" font-size="12.5" font-weight="600" fill="#fff">로그 · 모니터링</text>
|
||||
<text x="465" y="198" text-anchor="middle" font-size="11.5" fill="#9EB6D6">샌드박스 · 키 관리 · 호출 이력 · 통계</text>
|
||||
|
||||
<path d="M614 124h96" stroke="#0049B4" stroke-width="2.4"/><path d="m706 118 10 6-10 6" fill="#0049B4"/>
|
||||
<text x="662" y="112" text-anchor="middle" font-size="11.5" font-weight="700" fill="#0049B4">내부 전문</text>
|
||||
|
||||
<rect x="722" y="48" width="212" height="152" rx="14" fill="#EDF9FE" stroke="#C6DEF5"/>
|
||||
<text x="828" y="38" text-anchor="middle" font-size="13" font-weight="700" fill="#0049B4">CORE BANKING</text>
|
||||
<text x="828" y="102" text-anchor="middle" font-size="16" font-weight="800" fill="#0B2A5B">[[${brandName}]] 계정계</text>
|
||||
<text x="828" y="130" text-anchor="middle" font-size="12.5" fill="#55688A">수신 · 여신 · 외환 원장</text>
|
||||
<text x="828" y="150" text-anchor="middle" font-size="12.5" fill="#55688A">심사 · 컴플라이언스</text>
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- PROCESS -->
|
||||
<section class="intro-section" aria-labelledby="intro-process-title">
|
||||
<span class="intro-section__eyebrow">PROCESS</span>
|
||||
<h2 class="intro-section__title" id="intro-process-title">이용 절차</h2>
|
||||
<div class="intro-steps">
|
||||
<div class="intro-step">
|
||||
<div class="intro-step__icon"><svg width="30" height="30" viewBox="0 0 24 24" fill="none"><circle cx="10" cy="8" r="3.4" stroke="#0049B4" stroke-width="1.8"/><path d="M3 20c.6-3.6 3.3-5.4 7-5.4" stroke="#0049B4" stroke-width="1.8" stroke-linecap="round"/><path d="M17 13v7m-3.5-3.5h7" stroke="#00ACDD" stroke-width="1.8" stroke-linecap="round"/></svg></div>
|
||||
<div class="intro-step__body"><div class="intro-step__title"><em>STEP 01</em>회원가입</div><p>법인 회원으로 온라인 가입을 신청합니다.</p></div>
|
||||
</div>
|
||||
<div class="intro-step">
|
||||
<div class="intro-step__icon"><svg width="30" height="30" viewBox="0 0 24 24" fill="none"><rect x="4" y="3" width="16" height="18" rx="3" stroke="#0049B4" stroke-width="1.8"/><path d="m8.5 12 2.5 2.5 4.5-5" stroke="#00ACDD" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
|
||||
<div class="intro-step__body"><div class="intro-step__title"><em>STEP 02</em>승인</div><p>운영 담당자가 신청 계정 정보를 확인한 후 승인합니다. 법인 관리자는 승인 이후 실제 사용할 직원(개발자)을 추가 등록합니다.</p></div>
|
||||
</div>
|
||||
<div class="intro-step">
|
||||
<div class="intro-step__icon"><svg width="30" height="30" viewBox="0 0 24 24" fill="none"><path d="M9.5 4.5 3.5 12l6 7.5" stroke="#F08A24" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/><path d="m14.5 4.5 6 7.5-6 7.5" stroke="#0049B4" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
|
||||
<div class="intro-step__body"><div class="intro-step__title"><em>STEP 03</em>API / APP 사용 신청</div><p>[내 앱]에서 애플리케이션을 등록하고 필요한 API를 선택해 신청하면 ClientID / Secret이 발급됩니다.</p></div>
|
||||
</div>
|
||||
<div class="intro-step">
|
||||
<div class="intro-step__icon"><svg width="30" height="30" viewBox="0 0 24 24" fill="none"><path d="M4 17.5 9 12l3.5 3.5L20 8" stroke="#0049B4" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/><path d="M15 8h5v5" stroke="#00ACDD" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
|
||||
<div class="intro-step__body"><div class="intro-step__title"><em>STEP 04</em>샌드박스 개발 · 테스트</div><p>테스트 키로 토큰 발급과 API 호출, 웹훅 수신을 검증합니다. 테스트 데이터는 실제 원장에 반영되지 않습니다.</p></div>
|
||||
</div>
|
||||
<div class="intro-step">
|
||||
<div class="intro-step__icon"><svg width="30" height="30" viewBox="0 0 24 24" fill="none"><path d="M12 3 4 6.5v6c0 4.5 3.3 7.6 8 8.8 4.7-1.2 8-4.3 8-8.8v-6L12 3Z" stroke="#0049B4" stroke-width="1.8" stroke-linejoin="round"/><path d="m8.8 12.2 2.4 2.4 4.2-4.8" stroke="#00ACDD" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
|
||||
<div class="intro-step__body"><div class="intro-step__title"><em>STEP 05</em>운영 전환</div><p>보안 점검과 계약 절차를 마치면 운영 키가 발급되고 실거래 호출이 시작됩니다.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SECURITY -->
|
||||
<section class="intro-section" aria-labelledby="intro-security-title">
|
||||
<span class="intro-section__eyebrow">SECURITY & OPERATION</span>
|
||||
<h2 class="intro-section__title" id="intro-security-title">보안 및 운영 정책</h2>
|
||||
<div class="intro-grid3">
|
||||
<div class="intro-card">
|
||||
<h3>인증 · 통신</h3>
|
||||
<p>OAuth2 client_credentials 방식으로 발급한 토큰을 <code>X-AUTH-TOKEN</code> 헤더로 전달합니다. 모든 구간은 TLS 1.2 이상으로 암호화합니다.</p>
|
||||
</div>
|
||||
<div class="intro-card">
|
||||
<h3>접근 통제</h3>
|
||||
<p>앱 단위로 IP 허용목록과 호출 유량(rate limit)을 적용하여 운영하고 있습니다.</p>
|
||||
</div>
|
||||
<div class="intro-card">
|
||||
<h3>모니터링 · 지원</h3>
|
||||
<p>포탈에서 호출 이력·응답코드·지연 통계를 조회할 수 있으며, 장애 상황은 API Status 페이지와 등록 메일로 공지합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA -->
|
||||
<div class="intro-cta">
|
||||
<div class="intro-cta__body">
|
||||
<h3>[[${brandName}]] API, 지금 신청하세요</h3>
|
||||
<p>가입 후 앱을 등록하면 샌드박스 키가 발급되어 바로 개발을 시작할 수 있습니다.</p>
|
||||
</div>
|
||||
<a class="btn-action-primary" th:href="@{/signup}">개발자 회원가입</a>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</th:block>
|
||||
</body>
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
<rect x="160" y="90" width="80" height="50" rx="8" fill="#EDF9FE" stroke="#0049b4" />
|
||||
<text x="200" y="112" text-anchor="middle" font-size="10" font-weight="700"
|
||||
fill="#0049b4">DJBank</text>
|
||||
fill="#0049b4">[[${brandName}]]</text>
|
||||
<text x="200" y="126" text-anchor="middle" font-size="10" font-weight="700"
|
||||
fill="#0049b4">Open API</text>
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
개발 가이드 · 2-Legged · Client Credentials
|
||||
</span>
|
||||
<h1 class="oauth2-2legged__hero-title">OAuth2 개발가이드</h1>
|
||||
<p class="oauth2-2legged__hero-lead">DJBank Open API를 호출하기 위한 ClientID/Secret 기반 토큰
|
||||
<p class="oauth2-2legged__hero-lead">[[${brandName}]] Open API를 호출하기 위한 ClientID/Secret 기반 토큰
|
||||
발급과<br>Bearer 인증 호출 방법을 단계별로 설명합니다.</p>
|
||||
|
||||
</div>
|
||||
@@ -129,7 +129,7 @@
|
||||
<rect x="760" y="24" width="240" height="48" rx="24" fill="#FFFFFF"
|
||||
stroke="#0049b4" />
|
||||
<text x="880" y="54" text-anchor="middle" font-size="14" font-weight="700"
|
||||
fill="#0049b4">DJBank Open API</text>
|
||||
fill="#0049b4">[[${brandName}]] Open API</text>
|
||||
<line x1="880" y1="72" x2="880" y2="254" stroke="#94A3B8" stroke-dasharray="4 4" />
|
||||
</g>
|
||||
|
||||
@@ -441,7 +441,7 @@ HTTP/1.1 <span class="o2leg-g">200 OK</span>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="oauth2-2legged__error-note">⚠ error 코드는 RFC 6749 표준 코드 또는 DJBank 확장 코드</p>
|
||||
<p class="oauth2-2legged__error-note">⚠ error 코드는 RFC 6749 표준 코드 또는 [[${brandName}]] 확장 코드</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -451,7 +451,7 @@ HTTP/1.1 <span class="o2leg-g">200 OK</span>
|
||||
<div class="oauth2-2legged__cta-body">
|
||||
<span class="oauth2-2legged__cta-eyebrow">EXPLORE</span>
|
||||
<h2 class="oauth2-2legged__cta-title">API 목록 보러가기</h2>
|
||||
<p class="oauth2-2legged__cta-desc">사용 가능한 DJBank Open API 카탈로그를 확인하세요.</p>
|
||||
<p class="oauth2-2legged__cta-desc">사용 가능한 [[${brandName}]] Open API 카탈로그를 확인하세요.</p>
|
||||
<span class="oauth2-2legged__cta-button">API 목록 →</span>
|
||||
</div>
|
||||
<span class="oauth2-2legged__cta-deco oauth2-2legged__cta-deco--lg" aria-hidden="true"></span>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
개발 가이드 · Webhook · HMAC-SHA256
|
||||
</span>
|
||||
<h1 class="oauth2-2legged__hero-title">웹훅 개발가이드</h1>
|
||||
<p class="oauth2-2legged__hero-lead">DJBank가 발송하는 Webhook 요청의 진위를 확인하기 위한 HMAC-SHA256 서명 검증 방법을
|
||||
<p class="oauth2-2legged__hero-lead">[[${brandName}]][[${brandNameJosaGa}]] 발송하는 Webhook 요청의 진위를 확인하기 위한 HMAC-SHA256 서명 검증 방법을
|
||||
단계별로 설명합니다.</p>
|
||||
|
||||
<!-- <div class="oauth2-2legged__hero-chips">
|
||||
@@ -39,7 +39,7 @@
|
||||
|
||||
<rect x="24" y="92" width="86" height="52" rx="8" fill="#EDF9FE" stroke="#0049b4" />
|
||||
<text x="67" y="114" text-anchor="middle" font-size="10" font-weight="700"
|
||||
fill="#0049b4">DJBank</text>
|
||||
fill="#0049b4">[[${brandName}]]</text>
|
||||
<text x="67" y="128" text-anchor="middle" font-size="10" font-weight="700"
|
||||
fill="#0049b4">Webhook</text>
|
||||
|
||||
@@ -137,7 +137,7 @@
|
||||
<rect x="120" y="24" width="240" height="48" rx="24" fill="#EDF9FE"
|
||||
stroke="#0049b4" />
|
||||
<text x="240" y="54" text-anchor="middle" font-size="14" font-weight="700"
|
||||
fill="#0049b4">DJBank Webhook Sender</text>
|
||||
fill="#0049b4">[[${brandName}]] Webhook Sender</text>
|
||||
<line x1="240" y1="72" x2="240" y2="272" stroke="#94A3B8" stroke-dasharray="4 4" />
|
||||
</g>
|
||||
<g>
|
||||
@@ -175,7 +175,7 @@
|
||||
<section class="oauth2-2legged__step" aria-labelledby="whsig-step1-title">
|
||||
<span class="oauth2-2legged__eyebrow">STEP 1</span>
|
||||
<h2 class="oauth2-2legged__h2" id="whsig-step1-title">수신 요청 형식</h2>
|
||||
<p class="oauth2-2legged__desc">DJBank는 등록한 수신 URL로 아래 형태의 POST 요청을 전송합니다.</p>
|
||||
<p class="oauth2-2legged__desc">[[${brandName}]][[${brandNameJosaEun}]] 등록한 수신 URL로 아래 형태의 POST 요청을 전송합니다.</p>
|
||||
|
||||
<div class="oauth2-2legged__endpoint-box">
|
||||
<span class="oauth2-2legged__method">POST</span>
|
||||
@@ -210,10 +210,17 @@
|
||||
<td><code>Content-Type</code></td>
|
||||
<td>application/json</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>X-Webhook-Secret</code></td>
|
||||
<td>Webhook 관리 화면에서 직접 설정한 값 — <strong>설정한 경우에만</strong> 전송(선택)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="oauth2-2legged__warning">⚠ 서명 대상은 파싱 전 <strong>본문 원문(raw body)</strong> 입니다.
|
||||
</p>
|
||||
<p class="oauth2-2legged__warning">⚠ <code>X-Webhook-Secret</code>은 서명 검증과 무관합니다.
|
||||
Webhook 신청/수정 시 입력한 값을 그대로 echo 하는 헤더로, 수신측에서 추가로 값을 대조하고 싶을 때만
|
||||
사용하세요(값을 설정하지 않았다면 이 헤더는 아예 오지 않습니다).</p>
|
||||
</div>
|
||||
|
||||
<div class="oauth2-2legged__code-panel">
|
||||
@@ -222,10 +229,11 @@
|
||||
<span class="o2leg-c">"eventType"</span>: <span class="o2leg-y">"CHECK_START"</span>,
|
||||
<span class="o2leg-c">"eventId"</span>: <span class="o2leg-y">"f47ac10b-58cc-4372-a567-0e02b2c3d479"</span>,
|
||||
<span class="o2leg-c">"timestamp"</span>: <span class="o2leg-p">1723600000000</span>,
|
||||
<span class="o2leg-c">"message"</span>: <span class="o2leg-y">"DB 점검으로 15분간 서비스가 중단됩니다."</span>,
|
||||
<span class="o2leg-c">"data"</span>: [ <span class="o2leg-y">"TESTCASE003S1"</span>, <span class="o2leg-y">"TESTCASE005S1"</span> ]
|
||||
}
|
||||
|
||||
<span class="o2leg-g"># eventId: 발송 건 고유 ID · data: 영향 API 목록</span></pre>
|
||||
<span class="o2leg-g"># eventId: 발송 건 고유 ID · message: 관리자가 입력한 안내 문구(자유텍스트, 생략될 수 있음) · data: 영향 API 목록</span></pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -374,7 +382,7 @@ valid = constantTimeEquals(received, expected)</pre>
|
||||
<section class="oauth2-2legged__step" aria-labelledby="whsig-step4-title">
|
||||
<span class="oauth2-2legged__eyebrow">STEP 4</span>
|
||||
<h2 class="oauth2-2legged__h2" id="whsig-step4-title">응답(리턴) 반환 규칙</h2>
|
||||
<p class="oauth2-2legged__desc">수신 서버가 반환하는 HTTP 상태 코드에 따라 DJBank의 성공 판정과 재시도가 결정됩니다.</p>
|
||||
<p class="oauth2-2legged__desc">수신 서버가 반환하는 HTTP 상태 코드에 따라 [[${brandName}]]의 성공 판정과 재시도가 결정됩니다.</p>
|
||||
|
||||
<div class="oauth2-2legged__step-grid">
|
||||
<div class="oauth2-2legged__panel">
|
||||
@@ -384,7 +392,7 @@ valid = constantTimeEquals(received, expected)</pre>
|
||||
<tr>
|
||||
<th>반환</th>
|
||||
<th>상황</th>
|
||||
<th>DJBank 처리</th>
|
||||
<th>[[${brandName}]] 처리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -76,9 +76,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Secret Key -->
|
||||
<!-- 서명검증키 -->
|
||||
<div class="webhook-info-group">
|
||||
<span class="group-label">Secret Key</span>
|
||||
<span class="group-label">서명검증키</span>
|
||||
<div class="secret-action-row">
|
||||
<div class="secret-box" id="secretMasked" th:text="*{secretMasked}">************</div>
|
||||
<th:block sec:authorize="hasRole('ROLE_WEBHOOK')">
|
||||
@@ -88,6 +88,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 사용자 지정 Secret -->
|
||||
<div class="webhook-info-group">
|
||||
<span class="group-label">사용자 지정 Secret</span>
|
||||
<div class="secret-action-row" th:if="*{userSecretMasked != null and !#strings.isEmpty(userSecretMasked)}">
|
||||
<div class="secret-box" id="userSecretMasked" th:text="*{userSecretMasked}">************</div>
|
||||
<th:block sec:authorize="hasRole('ROLE_WEBHOOK')">
|
||||
<button type="button" class="btn-reveal-action" id="btnRevealUserSecret">조회</button>
|
||||
</th:block>
|
||||
</div>
|
||||
<div class="input-display-box" th:unless="*{userSecretMasked != null and !#strings.isEmpty(userSecretMasked)}">미설정</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -163,11 +175,23 @@
|
||||
|
||||
function showError(msg) { errorEl.textContent = msg; errorEl.style.display = 'block'; }
|
||||
|
||||
// Secret 조회
|
||||
// 비밀번호 5회 오답 → 서버가 세션을 강제 종료함. 경고 후 로그인 화면으로 이동.
|
||||
// true 반환 시 호출측은 이후 처리를 중단해야 한다.
|
||||
function handleForceLogout(res) {
|
||||
if (res && res.forceLogout) {
|
||||
alert(res.message || '비밀번호 확인 5회 실패로 로그아웃되었습니다.');
|
||||
window.location.href = '/login?pwFailExceeded=1';
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 서명검증키 조회
|
||||
var btnReveal = document.getElementById('btnRevealSecret');
|
||||
if (btnReveal) btnReveal.addEventListener('click', function () {
|
||||
openModal('Secret 조회', '비밀번호 확인 후 Secret Key를 표시합니다.', function (pw) {
|
||||
openModal('서명검증키 조회', '비밀번호 확인 후 서명검증키를 표시합니다.', function (pw) {
|
||||
post('/webhook/verify-secret', pw).then(function (res) {
|
||||
if (handleForceLogout(res)) { return; }
|
||||
if (res.success) {
|
||||
document.getElementById('secretMasked').textContent = res.secret;
|
||||
closeModal();
|
||||
@@ -176,17 +200,32 @@
|
||||
});
|
||||
});
|
||||
|
||||
// Secret 재발급
|
||||
// 서명검증키 재발급
|
||||
var btnRegen = document.getElementById('btnRegenSecret');
|
||||
if (btnRegen) btnRegen.addEventListener('click', function () {
|
||||
openModal('Secret 재발급',
|
||||
'재발급 시 기존 Secret은 즉시 무효화됩니다. 새 Secret을 수신 서버의 Webhook 서명검증에 반영하지 않으면 이후 발송되는 모든 Webhook의 서명 검증이 실패합니다. 계속하려면 비밀번호를 입력하세요.',
|
||||
openModal('서명검증키 재발급',
|
||||
'재발급 시 기존 서명검증키는 즉시 무효화됩니다. 새 서명검증키를 수신 서버의 Webhook 서명검증에 반영하지 않으면 이후 발송되는 모든 Webhook의 서명 검증이 실패합니다. 계속하려면 비밀번호를 입력하세요.',
|
||||
function (pw) {
|
||||
post('/webhook/regenerate-secret', pw).then(function (res) {
|
||||
if (handleForceLogout(res)) { return; }
|
||||
if (res.success) {
|
||||
document.getElementById('secretMasked').textContent = res.secret;
|
||||
closeModal();
|
||||
alert('Secret이 재발급되었습니다.\n반드시 수신 서버의 서명검증 Secret을 새 값으로 교체하세요.\n교체 전까지 Webhook 서명 검증이 실패합니다.');
|
||||
alert('서명검증키가 재발급되었습니다.\n반드시 수신 서버의 서명검증 키를 새 값으로 교체하세요.\n교체 전까지 Webhook 서명 검증이 실패합니다.');
|
||||
} else { showError(res.message || '실패했습니다.'); }
|
||||
}).catch(function () { showError('요청 처리 중 오류가 발생했습니다.'); });
|
||||
});
|
||||
});
|
||||
|
||||
// 사용자 지정 Secret 조회
|
||||
var btnRevealUserSecret = document.getElementById('btnRevealUserSecret');
|
||||
if (btnRevealUserSecret) btnRevealUserSecret.addEventListener('click', function () {
|
||||
openModal('사용자 지정 Secret 조회', '비밀번호 확인 후 값을 표시합니다.', function (pw) {
|
||||
post('/webhook/verify-secret', pw).then(function (res) {
|
||||
if (handleForceLogout(res)) { return; }
|
||||
if (res.success) {
|
||||
document.getElementById('userSecretMasked').textContent = res.userSecret || '';
|
||||
closeModal();
|
||||
} else { showError(res.message || '실패했습니다.'); }
|
||||
}).catch(function () { showError('요청 처리 중 오류가 발생했습니다.'); });
|
||||
});
|
||||
@@ -197,6 +236,7 @@
|
||||
if (btnDelete) btnDelete.addEventListener('click', function () {
|
||||
openModal('Webhook 삭제', '삭제하면 복구할 수 없습니다. 계속하려면 비밀번호를 입력하세요.', function (pw) {
|
||||
post('/webhook/delete', pw).then(function (res) {
|
||||
if (handleForceLogout(res)) { return; }
|
||||
if (res.success) { window.location.href = '/webhook'; }
|
||||
else { showError(res.message || '실패했습니다.'); }
|
||||
}).catch(function () { showError('요청 처리 중 오류가 발생했습니다.'); });
|
||||
|
||||
@@ -106,6 +106,17 @@
|
||||
<p class="field-help-red">이벤트 발생 시 이 URL로 서명된 POST 요청이 전송됩니다.</p>
|
||||
</div>
|
||||
|
||||
<!-- 사용자 지정 Secret -->
|
||||
<div class="s1-field">
|
||||
<label class="s1-label">사용자 지정 Secret (선택)</label>
|
||||
<input type="text" id="userSecret" th:field="*{userSecret}" class="s1-input"
|
||||
placeholder="발송 요청 헤더에 그대로 포함될 값" autocomplete="off" maxlength="500"
|
||||
pattern="[\x20-\x7E]*" title="ASCII 문자만 입력 가능합니다(한글 등 유니코드 불가).">
|
||||
<p class="field-error" th:if="${#fields.hasErrors('userSecret')}" th:errors="*{userSecret}">오류</p>
|
||||
<p class="field-help-red" th:if="${userSecretSet}">현재 값이 설정되어 있습니다. 공란으로 두면 기존 값이 유지되고, 값을 입력하면 교체됩니다. (ASCII 문자만 가능, 한글 등 유니코드 불가)</p>
|
||||
<p class="field-help" th:unless="${userSecretSet}">입력한 값은 Webhook 발송 시 요청 헤더에 그대로 포함되어 전달됩니다. 수신측 값 검증 용도로 사용하세요. (영문·숫자·특수문자 등 ASCII 문자만 가능, 한글 등 유니코드 불가)</p>
|
||||
</div>
|
||||
|
||||
<!-- 알림 이벤트 -->
|
||||
<div class="s1-field">
|
||||
<label class="s1-label">알림 받을 이벤트 <span class="s1-required">*</span></label>
|
||||
|
||||
@@ -153,7 +153,7 @@
|
||||
<div class="s3-message-wrapper">
|
||||
<h1 class="s3-success-title">Webhook 수정이 완료되었습니다.</h1>
|
||||
<p class="s3-success-desc">
|
||||
Secret Key는 변경되지 않았습니다.
|
||||
서명검증키는 변경되지 않았습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -80,6 +80,16 @@
|
||||
<p class="field-help">이벤트 발생 시 이 URL로 서명된 POST 요청이 전송됩니다.</p>
|
||||
</div>
|
||||
|
||||
<!-- 사용자 지정 Secret -->
|
||||
<div class="s1-field">
|
||||
<label class="s1-label">사용자 지정 Secret (선택)</label>
|
||||
<input type="text" id="userSecret" th:field="*{userSecret}" class="s1-input"
|
||||
placeholder="발송 요청 헤더에 그대로 포함될 값" autocomplete="off" maxlength="500"
|
||||
pattern="[\x20-\x7E]*" title="ASCII 문자만 입력 가능합니다(한글 등 유니코드 불가).">
|
||||
<p class="field-error" th:if="${#fields.hasErrors('userSecret')}" th:errors="*{userSecret}">오류</p>
|
||||
<p class="field-help">입력한 값은 Webhook 발송 시 요청 헤더에 그대로 포함되어 전달됩니다. 수신측 값 검증 용도로 사용하세요. (영문·숫자·특수문자 등 ASCII 문자만 가능, 한글 등 유니코드 불가)</p>
|
||||
</div>
|
||||
|
||||
<!-- 알림 이벤트 -->
|
||||
<div class="s1-field">
|
||||
<label class="s1-label">알림 받을 이벤트 <span class="s1-required">*</span></label>
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
<div class="webhook-complete">
|
||||
<div class="complete-icon">✅</div>
|
||||
<h3>Webhook이 등록되었습니다</h3>
|
||||
<p class="field-help">Secret Key는 [Webhook 관리]에서 비밀번호 확인 후 조회할 수 있습니다.</p>
|
||||
<p class="field-help">서명검증키는 [Webhook 관리]에서 비밀번호 확인 후 조회할 수 있습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -23,8 +23,10 @@
|
||||
- 폼 id 고정 "apiSelectorForm" — 제출 버튼은 form="apiSelectorForm" 으로 연결.
|
||||
- "이전" 버튼은 호출 페이지에 id="btnPrevStep" — 모듈 JS가 data-save-action 경로로 저장 POST 후 step1 복귀.
|
||||
- 추가 hidden 필드는 호출 페이지에서 form="apiSelectorForm" 속성으로 주입(예: apikey 수정 clientId).
|
||||
- API 목록: GET /apis/for_request (ROLE_API_KEY_REQUEST) AJAX.
|
||||
- 스타일: design s2-* (_apikey-register.scss step2 재작업분) 재사용.
|
||||
- API 목록: GET /apis/for_request (ROLE_API_KEY_REQUEST) AJAX. 카테고리/검색 전환 시 재조회 없이
|
||||
클라이언트에서 12건/페이지로 페이징(#apiPagination, api-selector.js PAGE_SIZE) — 전체선택/모달은 페이징과
|
||||
무관하게 필터된 전체 목록 기준으로 동작.
|
||||
- 스타일: design s2-* (_apikey-register.scss step2 재작업분) + 전역 .pagination(_pagination.scss) 재사용.
|
||||
*/-->
|
||||
<th:block th:fragment="apiSelector(apiServices, selectedApis, formAction, saveAction)">
|
||||
|
||||
@@ -94,6 +96,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination (JS 렌더 — fragment/pagination.html 과 동일 마크업/클래스, 전역 CSS 재사용) -->
|
||||
<div class="pagination" id="apiPagination"></div>
|
||||
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<div class="footer-content">
|
||||
<!-- Left Section -->
|
||||
<div class="footer-left">
|
||||
<img src="/img/logo/logo-jjb.png" alt="DJBank" class="footer-logo">
|
||||
<img th:src="@{${brandLogoFooterPath}}" alt="DJBank" class="footer-logo">
|
||||
<div class="footer-links">
|
||||
<a th:href="@{/agreements/terms}" class="footer-link">이용약관</a>
|
||||
<span class="footer-separator">|</span>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<div>
|
||||
<div class="logo">
|
||||
<a th:href="@{/}" class="mobile-logo-link">
|
||||
<img src="/img/logo/logo-djb.png" alt="DJBank" class="mobile-logo">
|
||||
<img th:src="@{${brandLogoHeaderPath}}" alt="DJBank" class="mobile-logo">
|
||||
</a>
|
||||
<a th:href="@{/}" class="mobile-logo-text" style="font-size: 22px; font-weight: 700; color: #212529; text-decoration: none; margin-left: 8px; vertical-align: middle;">API Portal</a>
|
||||
<span th:if="${activeProfileBadge != null}" class="env-badge" th:text="${activeProfileBadge}">dev</span>
|
||||
@@ -97,7 +97,7 @@
|
||||
<div class="mobile-header">
|
||||
<div class="mobile-left">
|
||||
<a th:href="@{/}" class="mobile-logo-link">
|
||||
<img src="/img/logo/logo-djb.png" alt="DJBank" class="mobile-logo">
|
||||
<img th:src="@{${brandLogoHeaderPath}}" alt="DJBank" class="mobile-logo">
|
||||
</a>
|
||||
<a th:href="@{/}" class="mobile-logo-text">API Portal</a>
|
||||
</div>
|
||||
@@ -140,7 +140,7 @@
|
||||
<div class="drawer-header">
|
||||
<div class="drawer-header-left">
|
||||
<a th:href="@{/}" class="drawer-logo-link">
|
||||
<img th:src="@{/img/logo/logo-djb.png}" alt="DJBank" class="drawer-logo">
|
||||
<img th:src="@{${brandLogoHeaderPath}}" alt="DJBank" class="drawer-logo">
|
||||
</a>
|
||||
<span class="drawer-logo-text">API Portal</span>
|
||||
</div>
|
||||
@@ -161,7 +161,7 @@
|
||||
|
||||
<!-- Welcome Section (Anonymous) -->
|
||||
<div class="drawer-welcome" sec:authorize="isAnonymous()">
|
||||
<p class="welcome-text">DJBank API Portal에 오신것을 환영합니다.</p>
|
||||
<p class="welcome-text">[[${brandName}]] API Portal에 오신것을 환영합니다.</p>
|
||||
<div class="welcome-buttons">
|
||||
<a th:href="@{/signup}" class="btn-drawer-signup">회원가입</a>
|
||||
<a th:href="@{/login}" class="btn-drawer-login">로그인</a>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<!-- 헤더 -->
|
||||
<div class="tfa-head">
|
||||
<span class="tfa-head-title" id="tfaTitle">추가 인증</span>
|
||||
<span class="tfa-head-brand">DJBank</span>
|
||||
<span class="tfa-head-brand">[[${brandName}]]</span>
|
||||
</div>
|
||||
|
||||
<div class="tfa-body">
|
||||
|
||||
Reference in New Issue
Block a user