앱 관리

This commit is contained in:
현성필
2025-11-01 11:49:11 +09:00
parent 6841c9236d
commit 563a3f054c
46 changed files with 9740 additions and 2062 deletions
@@ -4,18 +4,11 @@ package com.eactive.apim.portal.apps.apis.controller;
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
import com.eactive.apim.portal.apps.apis.service.ApiService;
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceApiDTO;
import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceDTO;
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
import com.eactive.apim.portal.apps.app.dto.ClientDTO;
import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.annotation.Secured;
import org.springframework.web.bind.annotation.GetMapping;
@@ -28,11 +21,8 @@ import org.springframework.web.bind.annotation.RestController;
@RequiredArgsConstructor
public class ApiAjaxController {
private final static String STG_SERVER = "STG";
private final ApiService apiService;
private final ApiServiceService apiServiceService;
private final AppServiceFacade appServiceFacade;
public Map<String, ApiServiceDTO> getMainIconsFromServiceDtos(List<ApiServiceDTO> apiServices) {
Map<String, ApiServiceDTO> mainIconsMap = new HashMap<>();
@@ -54,53 +44,15 @@ public class ApiAjaxController {
Map<String, ApiServiceDTO> mainIconsMap = getMainIconsFromServiceDtos(apiServices);
List<ApiSpecInfoDto> apiSpecInfoDtos = apiService.findApis();
for (ApiSpecInfoDto spec : apiSpecInfoDtos) {
ApiServiceDTO serviceDTO = mainIconsMap.get(spec.getApiId());
if (serviceDTO != null) {
String mainIcon = serviceDTO.getMainIcon();
String service = serviceDTO.getGroupName();
spec.setMainIcon(mainIcon);
spec.setService(service);
}
}
return apiSpecInfoDtos;
}
@GetMapping("/for_request_prod")
@Secured("ROLE_API_KEY_REQUEST")
public List<ApiSpecInfoDto> apiRequestProd(@ModelAttribute ApiGroupSearch search) {
List<ApiServiceDTO> apiServices = apiServiceService.searchApiGroups(search);
Map<String, ApiServiceDTO> mainIconsMap = getMainIconsFromServiceDtos(apiServices);
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
List<ClientDTO> apikeys = appServiceFacade.getApiKeyList(user.getPortalOrg(), STG_SERVER);
List<ApiServiceApiDTO> apis = apikeys.stream()
.flatMap(client -> client.getApiList().stream()) // flatten all apiLists into a single stream of ApiServiceApiDTO
.distinct() // remove duplicates
.collect(Collectors.toList()); // collect to List
List<ApiSpecInfoDto> apiSpecInfoDtos = new ArrayList<>();
apis.forEach(api -> {
ApiSpecInfoDto specInfoDto = new ApiSpecInfoDto();
specInfoDto.setApiId(api.getApiId());
specInfoDto.setApiName(api.getApiDesc());
apiSpecInfoDtos.add(specInfoDto);
});
for (ApiSpecInfoDto spec : apiSpecInfoDtos) {
ApiServiceDTO serviceDTO = mainIconsMap.get(spec.getApiId());
if (serviceDTO != null) {
String mainIcon = serviceDTO.getMainIcon();
String service = serviceDTO.getGroupName();
spec.setMainIcon(mainIcon);
spec.setService(service);
}
}
return apiSpecInfoDtos;
// Filter APIs that have matching service information
return apiSpecInfoDtos.stream()
.filter(spec -> mainIconsMap.containsKey(spec.getApiId()))
.peek(spec -> {
ApiServiceDTO serviceDTO = mainIconsMap.get(spec.getApiId());
spec.setMainIcon(serviceDTO.getMainIcon());
spec.setService(serviceDTO.getGroupName());
})
.collect(java.util.stream.Collectors.toList());
}
}
@@ -1,17 +1,17 @@
package com.eactive.apim.portal.apps.apis.controller;
import com.eactive.apim.portal.apps.apis.dto.ApiSearchData;
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
import com.eactive.apim.portal.apps.apis.service.ApiSearchFacade;
import com.eactive.apim.portal.apps.apis.service.ApiService;
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceDTO;
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
import java.util.*;
import com.eactive.apim.portal.common.exception.NotFoundException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
@@ -1,112 +0,0 @@
package com.eactive.apim.portal.apps.app.controller;
import com.eactive.apim.portal.apprequest.entity.AppRequestType;
import com.eactive.apim.portal.apps.apis.dto.ApiSearchData;
import com.eactive.apim.portal.apps.apis.service.ApiSearchFacade;
import com.eactive.apim.portal.apps.app.dto.AppRequestDTO;
import com.eactive.apim.portal.apps.app.dto.ClientDTO;
import com.eactive.apim.portal.apps.app.dto.ClientSimpleDTO;
import com.eactive.apim.portal.apps.app.mapper.CredentialMapper;
import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
import com.eactive.apim.portal.common.dto.ResponseDTO;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.EncryptionUtil;
import com.eactive.apim.portal.common.util.SecurityUtil;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.List;
import java.util.stream.Collectors;
import javax.crypto.BadPaddingException;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.security.access.annotation.Secured;
import org.springframework.ui.Model;
import org.springframework.validation.ValidationUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/myapikey")
@RequiredArgsConstructor
@Secured("ROLE_API_KEY_REQUEST")
public class ApiKeyRestController {
private final static String PROD_SERVER = "PROD";
private final static String STG_SERVER = "STG";
private final AppServiceFacade appServiceFacade;
private final CredentialMapper credentialMapper;
private final EncryptionUtil encryptionUtil;
private final ApiSearchFacade apiSearchFacade;
@PostMapping("/api_key_request")
public ResponseDTO apiRequest(@RequestBody AppRequestDTO appRequest) {
appRequest.setOrg(SecurityUtil.getUserOrg());
if (appRequest.getType() == null) {
appRequest.setType(AppRequestType.NEW);
}
AppRequestDTO appRequestDTO = appServiceFacade.createAppRequest(appRequest);
appServiceFacade.beginApproval(appRequestDTO.getApproval().getId());
return new ResponseDTO(200, "SUCCESS", appRequestDTO.getType().getDescription() + " 신청이 완료되었습니다.");
}
@PostMapping("/api_key_request_prod")
public ResponseDTO requestProdApiKey(@RequestBody AppRequestDTO appRequest) {
appRequest.setOrg(SecurityUtil.getUserOrg());
if (appRequest.getClientId() != null && !appRequest.getClientId().isEmpty()) {
try {
appRequest.setClientId(encryptionUtil.decrypt(appRequest.getClientId()));
} catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) {
return new ResponseDTO(400, "FAILURE", "API 키 정보가 올바르지 않습니다.");
}
appRequest.setType(AppRequestType.PROD_MODIFY);
} else {
appRequest.setType(AppRequestType.PROD_NEW);
}
AppRequestDTO appRequestDTO = appServiceFacade.createAppRequest(appRequest);
appServiceFacade.beginApproval(appRequestDTO.getApproval().getId());
return new ResponseDTO(200, "SUCCESS", appRequestDTO.getType().getDescription() + " 신청이 완료되었습니다.");
}
@GetMapping("/dev")
public List<ClientSimpleDTO> apiKeyList() {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
List<ClientDTO> apikeys = appServiceFacade.getApiKeyList(user.getPortalOrg(), STG_SERVER);
ApiSearchData apiSearchData = apiSearchFacade.loadAllApiData();
for (ClientDTO apikey : apikeys) {
apikey.getApiList().forEach(api -> {
api.setService(apiSearchData.getServicesByApiId().get(api.getApiId()).getGroupName());
});
}
return apikeys.stream().map(credentialMapper::mapToClientSimpleDTO).collect(Collectors.toList());
}
@PostMapping("/prod")
public ClientSimpleDTO apiKeyProdList(@RequestBody AppRequestDTO appRequest) {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
try {
ClientDTO apiKey = appServiceFacade.getApiKey(user.getPortalOrg().getId(), encryptionUtil.decrypt(appRequest.getClientId()), PROD_SERVER);
ApiSearchData apiSearchData = apiSearchFacade.loadAllApiData();
apiKey.getApiList().forEach(api -> {
api.setService(apiSearchData.getServicesByApiId().get(api.getApiId()).getGroupName());
});
apiKey.setClientid(encryptionUtil.encrypt(apiKey.getClientid()));
return credentialMapper.mapToClientSimpleDTO(apiKey);
} catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) {
return new ClientSimpleDTO();
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,111 @@
package com.eactive.apim.portal.apps.app.dto;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;
import lombok.Data;
import org.springframework.web.multipart.MultipartFile;
/**
* API Key Registration Session Data
* Holds data across multiple registration steps
*/
@Data
public class ApiKeyRegistrationDTO implements Serializable {
private static final long serialVersionUID = 1L;
// Client ID for modification operations
private String clientId;
// Request type for tracking operation type
private String requestType; // "NEW" or "MODIFY"
// Existing app icon file ID (for modifications)
private String appIconFileId;
// Step 1: Basic Information
// Transient field for file upload (not serialized in session)
private transient MultipartFile appIcon;
// Persisted file data
private String appIconFileName;
private byte[] appIconData;
private String appIconContentType;
@NotBlank(message = "앱 이름을 입력해주세요.")
@Size(max = 100, message = "앱 이름은 100자를 초과할 수 없습니다.")
private String appName;
@NotBlank(message = "앱 설명을 입력해주세요.")
@Size(max = 500, message = "앱 설명은 500자를 초과할 수 없습니다.")
private String appDescription;
// Optional field - allows empty string or valid URL with http(s):// protocol
@Pattern(regexp = "^$|^https?://.*", message = "올바른 URL 형식이 아닙니다.")
private String callbackUrl;
private List<String> ipWhitelist = new ArrayList<>();
// Step 2: API Selection
private List<String> selectedApis = new ArrayList<>();
// Helper method to add IP to whitelist
public void addIpAddress(String ip) {
if (ip != null && !ip.trim().isEmpty() && !ipWhitelist.contains(ip)) {
ipWhitelist.add(ip);
}
}
// Helper method to set IP whitelist from comma-separated string
public void setIpWhitelistFromString(String ipString) {
ipWhitelist.clear();
if (ipString != null && !ipString.trim().isEmpty()) {
String[] ips = ipString.split(",");
for (String ip : ips) {
String trimmedIp = ip.trim();
if (!trimmedIp.isEmpty()) {
ipWhitelist.add(trimmedIp);
}
}
}
}
// Helper method to get IP whitelist as comma-separated string
public String getIpWhitelistAsString() {
return String.join(",", ipWhitelist);
}
// Helper method to get app icon as base64 data URI for image display
public String getAppIconBase64() {
if (appIconData == null || appIconData.length == 0) {
return null;
}
String base64Data = Base64.getEncoder().encodeToString(appIconData);
String contentType = appIconContentType != null ? appIconContentType : "image/png";
return "data:" + contentType + ";base64," + base64Data;
}
// Validation helpers
public boolean isStep1Complete() {
// callbackUrl is optional, so we only check required fields
// For modifications, appIconFileId indicates an existing icon
boolean hasIcon = (appIconData != null && appIconData.length > 0)
|| (appIconFileId != null && !appIconFileId.trim().isEmpty());
return hasIcon &&
appName != null && !appName.trim().isEmpty() &&
appDescription != null && !appDescription.trim().isEmpty();
}
public boolean isStep2Complete() {
return selectedApis != null && !selectedApis.isEmpty();
}
public boolean isComplete() {
return isStep1Complete() && isStep2Complete();
}
}
@@ -44,6 +44,14 @@ public class AppRequestDTO {
private String reason;
private String appDescription;
private String callbackUrl;
private String ipWhitelist;
private String appIconFileId;
private LocalDateTime createdDate;
private String prevApiList;
@@ -45,6 +45,10 @@ public class ClientDTO {
private String appstatus;
private String appDescription;
private String appIconFileId;
private List<ApiServiceApiDTO> apiList = new ArrayList<>();
}
@@ -4,6 +4,7 @@ package com.eactive.apim.portal.apps.app.mapper;
import com.eactive.apim.portal.apprequest.entity.AppRequest;
import com.eactive.apim.portal.apps.apiservice.mapper.ApiServiceApiMapper;
import com.eactive.apim.portal.apps.app.dto.AppRequestDTO;
import com.eactive.apim.portal.apps.app.dto.ClientDTO;
import com.eactive.apim.portal.apps.approval.mapper.ApprovalMapper;
import com.eactive.apim.portal.apps.user.mapper.PortalOrgMapper;
import com.eactive.eai.data.mapper.BaseMapperConfig;
@@ -13,4 +14,53 @@ import org.mapstruct.Mapper;
@Mapper(config = BaseMapperConfig.class, uses = {PortalOrgMapper.class, ApiServiceApiMapper.class, ApprovalMapper.class})
public interface AppRequestMapper extends GenericMapper<AppRequestDTO, AppRequest> {
/**
* AppRequest를 ClientDTO로 간단하게 매핑합니다.
* 뷰에서 AppRequest를 ClientDTO와 동일한 형식으로 표시하기 위해 사용됩니다.
*
* @param appRequest 앱 요청 엔티티
* @return ClientDTO 표시용 DTO
*/
default ClientDTO mapToClientDTO(AppRequest appRequest) {
if (appRequest == null) {
return null;
}
ClientDTO dto = new ClientDTO();
// 기본 식별 정보
dto.setClientid(appRequest.getId()); // AppRequest ID를 임시 client ID로 사용
dto.setClientname(appRequest.getClientName());
// 앱 상세 정보
dto.setAppDescription(appRequest.getAppDescription());
dto.setAppIconFileId(appRequest.getAppIconFileId());
// 네트워크 설정
dto.setRedirecturi(appRequest.getCallbackUrl());
dto.setAllowedips(appRequest.getIpWhitelist());
// 상태 정보 (ApprovalStatus → appstatus 매핑)
if (appRequest.getStatus() != null) {
switch (appRequest.getStatus()) {
case APPROVED:
dto.setAppstatus("1"); // 승인됨 = 활성화
break;
case DENIED:
dto.setAppstatus("0"); // 거부됨 = 비활성화
break;
default:
dto.setAppstatus("2"); // 대기중/진행중 = 보류
break;
}
}
// 수정 날짜 (createdDate를 modifiedon으로 사용)
dto.setModifiedon(appRequest.getCreatedDate());
// API 목록은 별도 처리 필요 (콤마로 구분된 문자열 → List<ApiServiceApiDTO>)
// 필요시 컨트롤러에서 추가 처리
return dto;
}
}
@@ -1,15 +0,0 @@
package com.eactive.apim.portal.apps.app.mapper;
import com.eactive.apim.portal.app.entity.ProdClient;
import com.eactive.apim.portal.apps.apiservice.mapper.ApiSpecInfoMapper;
import com.eactive.apim.portal.apps.app.dto.ClientDTO;
import com.eactive.eai.data.mapper.BaseMapperConfig;
import com.eactive.eai.data.mapper.GenericMapper;
import org.mapstruct.Mapper;
@Mapper(config = BaseMapperConfig.class, uses = {ApiSpecInfoMapper.class})
public interface ProdClientMapper extends GenericMapper<ClientDTO, ProdClient> {
}
@@ -7,12 +7,14 @@ import com.eactive.apim.portal.apprequest.entity.AppRequest;
import com.eactive.apim.portal.apprequest.entity.AppRequestType;
import com.eactive.apim.portal.apprequest.repository.AppRequestRepository;
import com.eactive.apim.portal.approval.entity.Approval;
import com.eactive.apim.portal.approval.statemachine.ProcessingState;
import com.eactive.apim.portal.approval.statemachine.RequestedState;
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
import com.eactive.apim.portal.apps.apis.service.ApiService;
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceDTO;
import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceApiDTO;
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
import com.eactive.apim.portal.apps.app.dto.ApiKeyRegistrationDTO;
import com.eactive.apim.portal.apps.app.dto.AppRequestDTO;
import com.eactive.apim.portal.apps.app.dto.ClientDTO;
import com.eactive.apim.portal.apps.app.mapper.AppRequestMapper;
@@ -21,16 +23,24 @@ import com.eactive.apim.portal.apps.approval.service.ApprovalService;
import com.eactive.apim.portal.apps.user.dto.PortalOrgDTO;
import com.eactive.apim.portal.apps.user.mapper.PortalOrgMapper;
import com.eactive.apim.portal.common.exception.NotFoundException;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.ApiServiceHelper;
import com.eactive.apim.portal.file.entity.FileInfo;
import com.eactive.apim.portal.file.service.FileService;
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.*;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import javax.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Service
@@ -47,53 +57,64 @@ public class AppServiceFacade {
private final AppRequestMapper appRequestMapper;
private final PortalOrgMapper portalOrgMapper;
private final ApiServiceHelper apiServiceHelper;
private final FileService fileService;
private final PasswordEncoder passwordEncoder;
public Page<ClientDTO> getApiKeyPageForServer(Pageable pageable, PortalOrg portalOrg, String server) {
Page<Credential> clients = credentialRepository.findAllByOrgidAndServer(pageable, portalOrg.getId(), server);
return clients.map(credentialMapper::toVo);
}
public List<ClientDTO> getApikeyList(PortalOrg portalOrg) {
public List<ClientDTO> getApiKeyList(PortalOrg portalOrg, String server) {
List<Credential> clients = credentialRepository.findAllByOrgidAndServer(portalOrg.getId(), server);
List<Credential> clients = credentialRepository.findAllByOrgid(portalOrg.getId());
return clients.stream().map(credentialMapper::toVo).collect(Collectors.toList());
}
public ClientDTO getApiKey(String orgid, String clientId, String server) {
return credentialRepository.findByClientidAndOrgidAndServer(clientId, orgid, server).map(credentialMapper::toVo).orElse(null);
public List<AppRequest> getPendingApiKeyList(PortalOrg portalOrg) {
List<AppRequest> appRequests = appRequestRepository.findAllByOrgAndTypeIsInAndApproval_ApprovalStatusIn(portalOrg, Arrays.asList(AppRequestType.NEW, AppRequestType.MODIFY, AppRequestType.DELETE),
Arrays.asList(new ProcessingState(), new RequestedState()));
return appRequests;
}
public ClientDTO getApiKey(String orgid, String clientId) {
return credentialRepository.findByClientidAndOrgid(clientId, orgid).map(credentialMapper::toVo).orElse(null);
}
/**
* AppRequest ID와 조직 정보로 AppRequest를 조회합니다.
* API 그룹 및 API 목록 정보를 함께 처리하여 반환합니다.
*
* @param id AppRequest ID
* @param portalOrg 포털 조직
* @return AppRequest DTO (없으면 null)
*/
public AppRequestDTO getAppRequestById(String id, PortalOrg portalOrg) {
return getApiRequestByIdAndPortalOrg(id, portalOrg);
}
public Page<AppRequestDTO> getApiRequestHistory(Pageable pageable, PortalOrgDTO portalOrg) {
return appRequestRepository.findAllByOrgAndTypeIsIn(pageable, portalOrgMapper.toEntity(portalOrg), Arrays.asList(AppRequestType.NEW,AppRequestType.MODIFY, AppRequestType.DELETE)).map(appRequestMapper::toVo);
}
public Page<AppRequestDTO> getApiRequestProdHistory(Pageable pageable, PortalOrgDTO portalOrg) {
return appRequestRepository.findAllByOrgAndTypeIsIn(pageable, portalOrgMapper.toEntity(portalOrg), Arrays.asList(AppRequestType.PROD_NEW,AppRequestType.PROD_MODIFY, AppRequestType.PROD_DELETE)).map(appRequestMapper::toVo);
return appRequestRepository.findAllByOrgAndTypeIsIn(pageable, portalOrgMapper.toEntity(portalOrg), Arrays.asList(AppRequestType.NEW, AppRequestType.MODIFY, AppRequestType.DELETE)).map(appRequestMapper::toVo);
}
public AppRequestDTO createAppRequest(AppRequestDTO appRequest) {
setApiGroupsFromApiList(appRequest);
AppRequest request = appRequestRepository.save(appRequestMapper.toEntity(appRequest));
if (request.getType() != null && (request.getType().equals(AppRequestType.MODIFY) || request.getType().equals(AppRequestType.DELETE) ||
request.getType().equals(AppRequestType.PROD_MODIFY) || request.getType().equals(AppRequestType.PROD_DELETE))) {
if (request.getType() != null && (request.getType().equals(AppRequestType.MODIFY) || request.getType().equals(AppRequestType.DELETE))) {
Optional<Credential> client = credentialRepository.findById(request.getClientId());
if (client.isPresent()) {
request.setClientName(client.get().getClientname());
if(client.get().getApiList() != null) {
if (client.get().getApiList() != null) {
// 기존 API 목록을 저장
String existingApiList = client.get().getApiList().stream()
.map(ApiSpecInfo::getApiId)
.collect(Collectors.joining(","));
.map(ApiSpecInfo::getApiId)
.collect(Collectors.joining(","));
request.setPrevApiList(existingApiList);
}
if(AppRequestType.DELETE.equals(request.getType()) || AppRequestType.PROD_DELETE.equals(request.getType())) {
if (AppRequestType.DELETE.equals(request.getType())) {
request.setApiList(request.getPrevApiList());
}
} else {
@@ -108,22 +129,6 @@ public class AppServiceFacade {
return appRequestMapper.toVo(request);
}
private void setApiGroupsFromApiList(AppRequestDTO appRequest) {
if (StringUtils.isEmpty(appRequest.getApiList())) {
return;
}
Set<String> uniqueGroupIds = Arrays.stream(appRequest.getApiList().split(","))
.map(apiId -> apiServiceService.findApiServiceByApiId(apiId))
.filter(Objects::nonNull)
.flatMap(apiService -> apiService.getApiGroupApiList().stream())
.map(ApiServiceApiDTO::getApiGroupId)
.collect(Collectors.toSet());
appRequest.setApiGroupList(String.join(",", uniqueGroupIds));
}
public void beginApproval(String approvalId) {
approvalService.beginApproval(approvalId);
}
@@ -132,27 +137,11 @@ public class AppServiceFacade {
appRequestRepository.findByIdAndOrg(id, portalOrg).ifPresent(approvalService::cancelAppApproval);
}
public List<AppRequestDTO> getProdApiRequestHistory(String clientid) {
return appRequestRepository.findAllByClientIdsContainsAndTypeIsIn(clientid,Arrays.asList(AppRequestType.PROD_NEW,AppRequestType.PROD_MODIFY, AppRequestType.PROD_DELETE)).stream().map(appRequestMapper::toVo).collect(Collectors.toList());
}
public AppRequestDTO getApiRequestProdByIdAndPortalOrg(String id, PortalOrg portalOrg) {
AppRequestDTO appRequest = appRequestRepository.findByIdAndOrgAndTypeIsIn(id, portalOrg,
Arrays.asList(AppRequestType.PROD_NEW,AppRequestType.PROD_MODIFY, AppRequestType.PROD_DELETE)).map(this::convertToAppRequestDTO).orElse(null);
if(appRequest != null) {
processApiGroups(appRequest);
processApiList(appRequest);
}
return appRequest;
}
public AppRequestDTO getApiRequestByIdAndPortalOrg(String id, PortalOrg org) {
AppRequestDTO appRequest = appRequestRepository.findByIdAndOrg(id, org).map(this::convertToAppRequestDTO).orElse(null);
if(appRequest != null) {
processApiGroups(appRequest);
if (appRequest != null) {
processApiList(appRequest);
}
return appRequest;
@@ -167,16 +156,6 @@ public class AppServiceFacade {
return dto;
}
private void processApiGroups(AppRequestDTO appRequest) {
if (!StringUtils.isEmpty(appRequest.getApiGroupList())) {
String[] apiGroupList = appRequest.getApiGroupList().split(",");
for (String apiGroupId : apiGroupList) {
appRequest.getApiServiceDTOList().add(apiServiceService.getApiGroupById(apiGroupId));
}
}
}
private void processApiList(AppRequestDTO appRequest) {
if (!StringUtils.isEmpty(appRequest.getApiList())) {
@@ -192,4 +171,140 @@ public class AppServiceFacade {
}
}
}
/**
* ApiKeyRegistrationDTO를 기반으로 AppRequest를 생성합니다.
*
* @param registration API Key 등록 정보
* @param portalOrg 포털 조직
* @return 생성된 AppRequest DTO
* @throws IOException 파일 처리 중 오류 발생 시
*/
public AppRequestDTO createAppRequestFromRegistration(ApiKeyRegistrationDTO registration, PortalOrg portalOrg) throws IOException {
// 1. 앱 아이콘 파일 저장
String appIconFileId = null;
if (registration.getAppIconData() != null && registration.getAppIconData().length > 0) {
FileInfo fileInfo = fileService.createSingleFileFromBytes(registration.getAppIconFileName(), registration.getAppIconContentType(), registration.getAppIconData());
appIconFileId = fileInfo.getFileId();
}
// 2. AppRequestDTO 생성 및 필드 매핑
AppRequestDTO appRequestDTO = new AppRequestDTO();
appRequestDTO.setOrg(portalOrgMapper.toVo(portalOrg));
appRequestDTO.setType(AppRequestType.NEW);
// 기본 정보 매핑
appRequestDTO.setClientName(registration.getAppName());
appRequestDTO.setAppDescription(registration.getAppDescription());
appRequestDTO.setCallbackUrl(registration.getCallbackUrl());
appRequestDTO.setAppIconFileId(appIconFileId);
// IP 화이트리스트 매핑 (List<String> → comma-separated String)
if (registration.getIpWhitelist() != null && !registration.getIpWhitelist().isEmpty()) {
appRequestDTO.setIpWhitelist(String.join(",", registration.getIpWhitelist()));
}
// 선택된 API 목록 매핑 (List<String> → comma-separated String)
if (registration.getSelectedApis() != null && !registration.getSelectedApis().isEmpty()) {
appRequestDTO.setApiList(String.join(",", registration.getSelectedApis()));
}
// 3. 기존 createAppRequest 메소드 호출하여 승인 프로세스 시작
return createAppRequest(appRequestDTO);
}
/**
* ApiKeyRegistrationDTO를 기반으로 수정 AppRequest를 생성합니다.
*
* @param registration API Key 수정 정보
* @param portalOrg 포털 조직
* @return 생성된 AppRequest DTO
* @throws IOException 파일 처리 중 오류 발생 시
*/
public AppRequestDTO createModifyRequestFromRegistration(ApiKeyRegistrationDTO registration, PortalOrg portalOrg) throws IOException {
// 1. 기존 Credential 정보 조회
ClientDTO existingClient = getApiKey(portalOrg.getId(), registration.getClientId());
if (existingClient == null) {
throw new NotFoundException("Client not found: " + registration.getClientId());
}
// 2. 앱 아이콘 파일 저장 (변경된 경우)
String appIconFileId = null;
// 새로운 아이콘 데이터가 있으면 저장
if (registration.getAppIconData() != null && registration.getAppIconData().length > 0) {
FileInfo fileInfo = fileService.createSingleFileFromBytes(registration.getAppIconFileName(), registration.getAppIconContentType(), registration.getAppIconData());
appIconFileId = fileInfo.getFileId();
}
// 새로운 아이콘이 없으면 기존 아이콘 ID 사용 (registration에서 가져오거나 기존 클라이언트에서 가져옴)
else if (registration.getAppIconFileId() != null) {
appIconFileId = registration.getAppIconFileId();
} else {
appIconFileId = existingClient.getAppIconFileId();
}
// 3. AppRequestDTO 생성 및 필드 매핑
AppRequestDTO appRequestDTO = new AppRequestDTO();
appRequestDTO.setOrg(portalOrgMapper.toVo(portalOrg));
appRequestDTO.setType(AppRequestType.MODIFY);
appRequestDTO.setClientId(registration.getClientId()); // 수정할 클라이언트 ID 설정
// 기본 정보 매핑
appRequestDTO.setClientName(registration.getAppName());
appRequestDTO.setAppDescription(registration.getAppDescription());
appRequestDTO.setCallbackUrl(registration.getCallbackUrl());
appRequestDTO.setAppIconFileId(appIconFileId);
// IP 화이트리스트 매핑 (List<String> → comma-separated String)
if (registration.getIpWhitelist() != null && !registration.getIpWhitelist().isEmpty()) {
appRequestDTO.setIpWhitelist(String.join(",", registration.getIpWhitelist()));
}
// 선택된 API 목록 매핑 (List<String> → comma-separated String)
if (registration.getSelectedApis() != null && !registration.getSelectedApis().isEmpty()) {
appRequestDTO.setApiList(String.join(",", registration.getSelectedApis()));
}
// 4. 기존 createAppRequest 메소드 호출하여 승인 프로세스 시작
// createAppRequest 내부에서 MODIFY 타입인 경우 기존 API 목록을 prevApiList에 저장함
return createAppRequest(appRequestDTO);
}
/**
* 사용자의 비밀번호를 검증합니다.
* Client Secret 조회 등 민감한 작업 수행 시 본인 확인을 위해 사용됩니다.
*
* @param user 인증된 사용자 정보
* @param password 검증할 비밀번호 (평문)
* @return 비밀번호 일치 여부
*/
public boolean verifyUserPassword(PortalAuthenticatedUser user, String password) {
if (user == null || password == null) {
return false;
}
try {
// PasswordEncoder를 사용하여 평문 비밀번호와 저장된 해시를 비교
return passwordEncoder.matches(password, user.getPassword());
} catch (Exception e) {
// 비밀번호 검증 중 오류 발생 시 false 반환 (보안상 구체적인 오류 노출 방지)
return false;
}
}
/**
* API Key(Credential)를 즉시 삭제합니다.
* 승인 프로세스 없이 바로 삭제 처리됩니다.
*
* @param orgId 조직 ID
* @param clientId 삭제할 클라이언트 ID
* @throws NotFoundException 클라이언트를 찾을 수 없는 경우
*/
public void deleteApp(String orgId, String clientId) {
Credential credential = credentialRepository.findByClientidAndOrgid(clientId, orgId)
.orElseThrow(() -> new NotFoundException("Client not found: " + clientId));
credentialRepository.delete(credential);
}
}
@@ -4,7 +4,6 @@ import com.eactive.apim.portal.apps.dashboard.service.DashboardService;
import com.eactive.apim.portal.common.pagerouter.PageHandler;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import java.util.List;
import javax.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
@@ -22,9 +21,7 @@ public class DashboardHandler implements PageHandler {
if (SecurityUtil.getPortalAuthenticatedUser() != null && SecurityUtil.getPortalAuthenticatedUser().getPortalOrg() != null) {
PortalOrg org = SecurityUtil.getPortalAuthenticatedUser().getPortalOrg();
model.addAttribute("pendingKeyCount", dashboardService.getPendingApiKeyRequestCount(org));
model.addAttribute("testKeyCount", dashboardService.getTestApiKeyCount(org));
model.addAttribute("pendingProdCount", dashboardService.getProdApiKeyRequestCount(org));
model.addAttribute("prodKeyCount", dashboardService.getProdApiKeyCount(org));
model.addAttribute("testKeyCount", dashboardService.getApiKeyCount(org));
model.addAttribute("totalApi", dashboardService.getTotalApi(org));
model.addAttribute("dailyTotal", dashboardService.getDailyTotalApiUsage(org));
@@ -5,24 +5,18 @@ import com.eactive.apim.portal.app.entity.Credential;
import com.eactive.apim.portal.app.repository.CredentialRepository;
import com.eactive.apim.portal.apprequest.entity.AppRequestType;
import com.eactive.apim.portal.apprequest.repository.AppRequestRepository;
import com.eactive.apim.portal.approval.entity.ApprovalStatus;
import com.eactive.apim.portal.approval.statemachine.ProcessingState;
import com.eactive.apim.portal.approval.statemachine.RequestedState;
import com.eactive.apim.portal.apps.approval.service.ApprovalService;
import com.eactive.apim.portal.apps.dashboard.ApiDailyStatistics;
import com.eactive.apim.portal.apps.dashboard.repository.PortalStatisticsRepository;
import com.eactive.apim.portal.apps.dashboard.repository.PortalStatisticsRepository.TopApiUsage;
import com.eactive.apim.portal.common.util.DateUtil;
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import java.text.SimpleDateFormat;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -39,15 +33,12 @@ import org.springframework.transaction.annotation.Transactional;
@Service
public class DashboardService {
private final static String PROD_SERVER = "PROD";
private final static String STG_SERVER = "STG";
private final AppRequestRepository appRequestRepository;
private final CredentialRepository credentialRepository;
private final PortalStatisticsRepository portalStatisticsRepository;
public int getTestApiKeyCount(PortalOrg org) {
return credentialRepository.countAllByOrgidAndServer(org.getId(), STG_SERVER);
public int getApiKeyCount(PortalOrg org) {
return credentialRepository.countAllByOrgid(org.getId());
}
public int getPendingApiKeyRequestCount(PortalOrg org) {
@@ -55,18 +46,9 @@ public class DashboardService {
Arrays.asList(new ProcessingState(), new RequestedState()));
}
public int getProdApiKeyRequestCount(PortalOrg org) {
return appRequestRepository.countAllByOrgAndTypeIsInAndApproval_ApprovalStatusIn(org, Arrays.asList(AppRequestType.PROD_NEW, AppRequestType.PROD_MODIFY, AppRequestType.PROD_DELETE),
Arrays.asList(new ProcessingState(), new RequestedState()));
}
public int getProdApiKeyCount(PortalOrg org) {
return credentialRepository.findAllByOrgidAndServer(org.getId(), PROD_SERVER).size();
}
public int getTotalApi(PortalOrg org) {
List<Credential> credentials = credentialRepository.findAllByOrgidAndServer(org.getId(), PROD_SERVER);
List<Credential> credentials = credentialRepository.findAllByOrgid(org.getId());
return (int) credentials.stream()
.flatMap(credential -> credential.getApiList().stream())
.map(ApiSpecInfo::getApiId)
@@ -1,4 +1,4 @@
title.html=Kbank API Portal
title.html=KJbank API Portal
apim.route.register.file=\uCCA8\uBD80\uD30C\uC77C
field.required=\uD544\uC218 \uC785\uB825\uD56D\uBAA9\uC785\uB2C8\uB2E4.
companyName=\uD68C\uC0AC\uBA85
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -26,6 +26,133 @@ const customPopups = {
// 이벤트 리스너 제거
$(document).off('keydown.customAlert');
},
/**
* 비밀번호 입력 팝업 표시
* @param {Object} options - 팝업 옵션
* @param {string} options.title - 팝업 제목 (기본값: "비밀번호 입력")
* @param {string} options.message - 안내 메시지 (기본값: "계속하려면 비밀번호를 입력해주세요.")
* @param {Function} options.onConfirm - 확인 버튼 클릭 호출되는 콜백 (파라미터: password)
* @param {Function} options.onCancel - 취소 버튼 클릭 호출되는 콜백 (선택사항)
*/
showPasswordInput: function (options) {
options = options || {};
// 기본값 설정
const title = options.title || '비밀번호 입력';
const message = options.message || '계속하려면 비밀번호를 입력해주세요.';
const onConfirm = options.onConfirm;
const onCancel = options.onCancel;
// 팝업 내용 설정
$('#passwordPopupTitle').text(title);
$('#passwordPopupMessage').text(message);
// 입력 필드 및 에러 초기화
$('#passwordPopupInput').val('').removeClass('error');
$('#passwordPopupError').removeClass('show').text('');
// 팝업 표시 (modal 구조 사용)
$('#passwordInputPopup').show();
setTimeout(function() {
$('#passwordModalBackdrop').addClass('show');
$('#passwordModal').addClass('show');
}, 10);
// Body 스크롤 방지
$('body').css('overflow', 'hidden');
// 입력 필드에 포커스
setTimeout(function() {
$('#passwordPopupInput').focus();
}, 350);
// 확인 버튼 이벤트 (기존 이벤트 제거 후 재등록)
$('#passwordPopupConfirmButton').off('click').on('click', function () {
const password = $('#passwordPopupInput').val().trim();
if (!password) {
customPopups.showPasswordError('비밀번호를 입력해주세요.');
return;
}
if (typeof onConfirm === 'function') {
onConfirm(password);
}
});
// 취소 버튼 이벤트
$('#passwordPopupCancelButton').off('click').on('click', function () {
customPopups.hidePasswordInput();
if (typeof onCancel === 'function') {
onCancel();
}
});
// 닫기 버튼 이벤트
$('#passwordPopupCloseButton').off('click').on('click', function () {
customPopups.hidePasswordInput();
if (typeof onCancel === 'function') {
onCancel();
}
});
// Enter 키 이벤트
$('#passwordPopupInput').off('keypress').on('keypress', function (e) {
if (e.which === 13) {
$('#passwordPopupConfirmButton').click();
}
});
// 입력 시 에러 초기화
$('#passwordPopupInput').off('input').on('input', function () {
customPopups.clearPasswordError();
});
},
/**
* 비밀번호 입력 팝업 숨기기
*/
hidePasswordInput: function () {
// Modal 숨김 애니메이션
$('#passwordModalBackdrop').removeClass('show');
$('#passwordModal').removeClass('show');
// 애니메이션 완료 후 숨김
setTimeout(function() {
$('#passwordInputPopup').hide();
}, 300);
// Body 스크롤 복원
$('body').css('overflow', '');
// 입력 필드 초기화
$('#passwordPopupInput').val('').removeClass('error');
$('#passwordPopupError').removeClass('show').text('');
// 이벤트 리스너 제거
$('#passwordPopupConfirmButton').off('click');
$('#passwordPopupCancelButton').off('click');
$('#passwordPopupCloseButton').off('click');
$('#passwordPopupInput').off('keypress').off('input');
},
/**
* 비밀번호 입력 에러 메시지 표시
* @param {string} message - 에러 메시지
*/
showPasswordError: function (message) {
$('#passwordPopupError').text(message).addClass('show');
$('#passwordPopupInput').addClass('error');
},
/**
* 비밀번호 입력 에러 메시지 제거
*/
clearPasswordError: function () {
$('#passwordPopupError').removeClass('show');
$('#passwordPopupInput').removeClass('error');
},
showConfirm: function (message, callback) {
$('#customConfirmMessage').text(message);
$('#customConfirm').css('display', 'flex');
@@ -0,0 +1,73 @@
// ============================================
// Password Input Popup Component
// Extends base modal styles from _modals.scss
// ============================================
// Password popup specific styles (using modal structure)
#passwordInputPopup {
// Modal dialog size override
.modal-dialog {
max-width: 450px;
}
// Password input group
.pop_input_group {
text-align: left;
margin: 0;
}
.pop_input_field {
width: 100%;
padding: 12px $spacing-md;
border: 1px solid $border-gray;
border-radius: $border-radius-md;
font-size: $font-size-sm;
font-family: $font-family-primary;
transition: all 0.3s ease;
box-sizing: border-box;
&:focus {
outline: none;
border-color: $primary-blue;
box-shadow: 0 0 0 3px rgba($primary-blue, 0.1);
}
&::placeholder {
color: $text-light;
}
// Error state
&.error {
border-color: $accent-orange;
&:focus {
border-color: $accent-orange;
box-shadow: 0 0 0 3px rgba($accent-orange, 0.1);
}
}
}
.error-message {
color: $accent-orange;
font-size: $font-size-xs;
margin-top: $spacing-xs;
display: none;
animation: fadeInDown 0.3s ease;
&.show {
display: block;
}
}
}
// Animation for error message
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@@ -69,7 +69,7 @@
align-items: center;
justify-content: space-between;
height: 80px;
max-width: 1280px;
max-width: $container-max-width;
margin: 0 auto;
padding: 0 20px;
}
+4
View File
@@ -28,6 +28,7 @@
@import 'components/navigation';
@import 'components/partners';
@import 'components/cta';
@import 'components/password-popup';
// 5. Page-specific styles
@import 'pages/index';
@@ -36,6 +37,9 @@
@import 'pages/api-market';
@import 'pages/documentation';
@import 'pages/login';
@import 'pages/mypage';
@import 'pages/apikey-register';
@import 'pages/apikey-detail';
// 6. Themes
@import 'themes/dark';
@@ -0,0 +1,476 @@
@charset "utf-8";
// ====================================
// API Key Detail Pages Styles
// For appRequestDetail.html and credentialDetail.html
// ====================================
// Container Styles
.apikey-detail-container {
max-width: 1200px;
margin: 0 auto;
padding: $spacing-4xl;
padding-top: 0;
@media (max-width: $breakpoint-sm) {
padding: $spacing-md;
}
}
.register-header {
text-align: center;
margin-bottom: $spacing-5xl;
h1 {
font-size: $font-size-4xl;
font-weight: $font-weight-bold;
color: $text-dark;
margin-bottom: $spacing-sm;
}
.header-description {
font-size: $font-size-base;
color: $text-gray;
}
}
// Status Section
.status-section {
text-align: center;
margin-bottom: $spacing-4xl;
}
.status-badge {
display: inline-block;
padding: $spacing-sm $spacing-lg;
border-radius: $border-radius-full;
font-weight: $font-weight-semibold;
font-size: $font-size-sm;
&.badge-pending {
background-color: #FFF3CD;
color: #856404;
}
&.badge-approved {
background-color: #D4EDDA;
color: #155724;
}
&.badge-rejected {
background-color: #F8D7DA;
color: #721C24;
}
}
// Detail Section
.detail-section {
background: $white;
border-radius: $border-radius-lg;
padding: $spacing-4xl;
margin-bottom: $spacing-lg;
box-shadow: $shadow-md;
@media (max-width: $breakpoint-sm) {
padding: $spacing-xl;
}
}
.section-title {
font-size: $font-size-xl;
font-weight: $font-weight-bold;
color: $text-dark;
margin-bottom: $spacing-lg;
padding-bottom: $spacing-md;
border-bottom: 2px solid $border-gray;
}
// Detail Grid
.detail-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: $spacing-lg;
@media (max-width: $breakpoint-sm) {
grid-template-columns: 1fr;
}
}
.detail-item {
display: flex;
flex-direction: column;
gap: $spacing-sm;
&.full-width {
grid-column: 1 / -1;
}
}
.detail-label {
font-size: $font-size-sm;
font-weight: $font-weight-semibold;
color: $text-gray;
}
.detail-value {
font-size: $font-size-base;
color: $text-dark;
word-break: break-word;
}
// App Icon
.app-icon-display {
width: 120px;
height: 120px;
}
.app-icon-image {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: $border-radius-lg;
border: 2px solid $border-gray;
}
.app-icon-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: $gray-bg;
border-radius: $border-radius-lg;
border: 2px dashed $text-light;
font-size: 48px;
}
// IP List
.ip-list {
display: flex;
flex-wrap: wrap;
gap: $spacing-sm;
}
.ip-tag {
display: inline-block;
padding: $spacing-xs $spacing-md;
background-color: $light-bg;
color: $secondary-blue;
border-radius: $border-radius-sm;
font-size: $font-size-sm;
font-family: $font-family-mono;
}
// Credential Code
.credential-code {
display: inline-block;
padding: $spacing-sm $spacing-md;
background-color: $gray-bg;
border: 1px solid $border-gray;
border-radius: $border-radius-sm;
font-family: $font-family-mono;
font-size: $font-size-sm;
color: $secondary-blue;
}
// Secret Box
.secret-box {
display: flex;
align-items: center;
gap: $spacing-md;
@media (max-width: $breakpoint-sm) {
flex-direction: column;
align-items: flex-start;
}
}
.btn-copy {
display: inline-flex;
align-items: center;
gap: $spacing-xs;
padding: $spacing-sm $spacing-md;
background-color: $primary-blue;
color: $white;
border: none;
border-radius: $border-radius-sm;
font-size: $font-size-sm;
font-weight: $font-weight-semibold;
cursor: pointer;
transition: all 0.2s;
&:hover {
background-color: $secondary-blue;
}
svg {
width: 16px;
height: 16px;
}
@media (max-width: $breakpoint-sm) {
align-self: stretch;
justify-content: center;
}
}
// Status Indicator
.status-indicator {
display: inline-flex;
align-items: center;
gap: $spacing-sm;
padding: $spacing-xs $spacing-md;
border-radius: $border-radius-xl;
font-size: $font-size-sm;
font-weight: $font-weight-semibold;
&.status-active {
background-color: #D4EDDA;
color: #155724;
}
&.status-inactive {
background-color: #F8D7DA;
color: #721C24;
}
}
.status-dot {
width: 8px;
height: 8px;
border-radius: $border-radius-circle;
background-color: currentColor;
}
// API Detail List
.api-detail-list {
display: flex;
flex-direction: column;
gap: $spacing-sm;
}
.api-list-item {
display: flex;
align-items: center;
gap: $spacing-md;
padding: $spacing-md $spacing-lg;
background: $white;
border: 1px solid $border-gray;
border-radius: $border-radius-md;
transition: all 0.2s;
&:hover {
border-color: $primary-blue;
box-shadow: $shadow-sm;
}
@media (max-width: $breakpoint-sm) {
padding: $spacing-md;
gap: $spacing-sm;
}
}
.api-method {
display: inline-block;
padding: $spacing-xs $spacing-md;
border-radius: $border-radius-sm;
font-size: $font-size-sm;
font-weight: $font-weight-bold;
font-family: $font-family-mono;
min-width: 80px;
text-align: center;
&.method-get {
background-color: #D1FAE5;
color: #065F46;
}
&.method-post {
background-color: #DBEAFE;
color: #1E40AF;
}
&.method-put {
background-color: #FEF3C7;
color: #92400E;
}
&.method-delete {
background-color: #FEE2E2;
color: #991B1B;
}
&.method-other {
background-color: $border-gray;
color: $text-dark;
}
}
.api-list-item .api-name {
flex: 1;
font-size: $font-size-base;
font-weight: $font-weight-medium;
color: $text-dark;
margin: 0;
}
.api-status {
padding: $spacing-xs $spacing-md;
border-radius: $border-radius-lg;
font-size: $font-size-sm;
font-weight: $font-weight-semibold;
&.status-pending {
background-color: #FFF3CD;
color: #856404;
}
&.status-active {
background-color: #D4EDDA;
color: #155724;
}
}
// History Table
.history-table-wrapper {
overflow-x: auto;
}
.history-table {
width: 100%;
border-collapse: collapse;
th {
background-color: $gray-bg;
padding: $spacing-md $spacing-md;
text-align: left;
font-weight: $font-weight-semibold;
color: $text-gray;
border-bottom: 2px solid $border-gray;
}
td {
padding: $spacing-md;
border-bottom: 1px solid $border-gray;
}
}
.history-status {
display: inline-block;
padding: $spacing-xs $spacing-md;
background-color: $light-bg;
color: $secondary-blue;
border-radius: $border-radius-lg;
font-size: $font-size-sm;
font-weight: $font-weight-semibold;
}
// Empty State
.empty-state {
grid-column: 1 / -1;
text-align: center;
padding: 48px $spacing-lg;
.empty-icon {
font-size: 64px;
margin-bottom: $spacing-md;
}
p {
font-size: $font-size-base;
color: $text-gray;
}
}
// Action Buttons
.detail-actions {
display: flex;
justify-content: center;
gap: $spacing-md;
flex-wrap: wrap;
margin-top: $spacing-4xl;
padding-top: $spacing-4xl;
border-top: 1px solid $border-gray;
@media (max-width: $breakpoint-sm) {
flex-direction: column;
}
}
.btn-action,
.btn-secondary {
padding: $spacing-md $spacing-4xl;
border-radius: $border-radius-md;
font-weight: $font-weight-semibold;
font-size: $font-size-base;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
display: inline-block;
border: none;
@media (max-width: $breakpoint-sm) {
width: 100%;
}
}
.btn-action {
&.btn-primary {
background-color: $primary-blue;
color: $white;
&:hover {
background-color: $secondary-blue;
}
}
&.btn-info {
background-color: $accent-cyan;
color: $white;
&:hover {
background-color: darken($accent-cyan, 10%);
}
}
&.btn-danger {
background-color: $accent-orange;
color: $white;
&:hover {
background-color: darken($accent-orange, 10%);
}
}
}
.btn-secondary,
.btn-cancel {
background-color: $gray-bg;
color: $text-dark;
border: 1px solid $border-gray;
&:hover {
background-color: $border-gray;
}
}
.btn-cancel {
padding: $spacing-md $spacing-4xl;
border-radius: $border-radius-md;
font-weight: $font-weight-semibold;
font-size: $font-size-base;
cursor: pointer;
transition: all 0.2s;
background-color: #FEE2E2;
color: #991B1B;
border: 1px solid #FCA5A5;
&:hover {
background-color: #FCA5A5;
color: #7F1D1D;
}
@media (max-width: $breakpoint-sm) {
width: 100%;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,343 @@
// -----------------------------------------------------------------------------
// MyPage - API Key List Card Layout
// Reference: apps/mypage/apiKeyList.html
// -----------------------------------------------------------------------------
// Container
.apikey-container {
max-width: $container-max-width;
margin: 0 auto;
padding: $spacing-2xl $spacing-lg;
@media (max-width: $breakpoint-sm) {
padding: $spacing-lg $spacing-md;
}
}
// Header Section
.apikey-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: $spacing-2xl;
padding-bottom: $spacing-lg;
border-bottom: 2px solid $border-gray;
@media (max-width: $breakpoint-sm) {
flex-direction: column;
align-items: flex-start;
gap: $spacing-lg;
}
}
.apikey-title {
h1 {
font-size: $font-size-sm;
color: $text-gray;
font-weight: $font-weight-regular;
margin-bottom: $spacing-xs;
}
h2 {
font-size: $font-size-2xl;
font-weight: $font-weight-bold;
color: $text-dark;
}
}
.apikey-actions {
.btn-create-app {
display: inline-flex;
align-items: center;
gap: $spacing-sm;
padding: 12px 24px;
background: $gradient-primary;
color: $white;
border: none;
border-radius: $border-radius-lg;
font-size: $font-size-base;
font-weight: $font-weight-semibold;
cursor: pointer;
transition: $transition-base;
box-shadow: $shadow-md;
&:hover {
transform: translateY(-2px);
box-shadow: $shadow-lg;
}
&:active {
transform: translateY(0);
}
.btn-icon {
font-size: 18px;
}
}
}
// Result Count
.apikey-result-count {
font-size: $font-size-base;
color: $text-gray;
margin-bottom: $spacing-xl;
strong {
color: $primary-blue;
font-weight: $font-weight-semibold;
}
}
// Card Grid - Single column layout (one card per row)
.apikey-card-grid {
display: grid;
grid-template-columns: 1fr;
gap: $spacing-lg;
margin-bottom: $spacing-2xl;
}
// API Key Card - Simplified horizontal layout
.apikey-card {
background: $white;
border: 1px solid $border-gray;
border-radius: $border-radius-lg;
box-shadow: $shadow-sm;
transition: $transition-base;
cursor: pointer;
display: flex;
flex-direction: row;
align-items: center;
gap: $spacing-lg;
padding: $spacing-lg;
position: relative;
overflow: hidden;
text-decoration: none;
color: inherit;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 4px;
background: $gradient-primary;
transform: scaleY(0);
transform-origin: top;
transition: transform 0.3s ease;
}
&:hover {
box-shadow: $shadow-lg;
transform: translateX(4px);
border-color: rgba($primary-blue, 0.3);
&::before {
transform: scaleY(1);
}
}
&:active {
transform: translateX(2px);
}
@media (max-width: $breakpoint-sm) {
flex-direction: column;
align-items: flex-start;
gap: $spacing-md;
}
}
// App Icon
.apikey-card-icon {
flex-shrink: 0;
width: 80px;
height: 80px;
border-radius: $border-radius-md;
overflow: hidden;
background: $gray-bg;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid $border-gray;
.app-icon-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.app-icon-placeholder {
color: $text-gray;
opacity: 0.5;
display: flex;
align-items: center;
justify-content: center;
}
@media (max-width: $breakpoint-sm) {
width: 60px;
height: 60px;
}
}
// App Content
.apikey-card-content {
flex: 1;
min-width: 0; // Allow text truncation
.apikey-card-title {
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
color: $text-dark;
margin-bottom: $spacing-xs;
}
.apikey-card-description {
font-size: $font-size-sm;
color: $text-gray;
line-height: 1.5;
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
}
// Status Wrapper
.apikey-card-status-wrapper {
flex-shrink: 0;
.apikey-card-status {
display: inline-flex;
align-items: center;
padding: 6px 16px;
border-radius: $border-radius-full;
font-size: $font-size-xs;
font-weight: $font-weight-semibold;
text-transform: uppercase;
letter-spacing: 0.5px;
white-space: nowrap;
&.status-active {
background-color: rgba($accent-green, 0.15);
color: #4FA065;
}
&.status-inactive {
background-color: rgba($accent-orange, 0.15);
color: #D9534F;
}
&.status-pending {
background-color: rgba($accent-yellow, 0.15);
color: #F0AD4E;
}
}
}
// Empty State
.apikey-empty-state {
text-align: center;
padding: $spacing-5xl $spacing-lg;
background: $white;
border-radius: $border-radius-lg;
border: 2px dashed $border-gray;
.empty-icon {
font-size: 80px;
margin-bottom: $spacing-lg;
opacity: 0.6;
display: inline-block;
animation: bounce 2s infinite;
}
h3 {
font-size: $font-size-xl;
font-weight: $font-weight-semibold;
color: $text-dark;
margin-bottom: $spacing-md;
}
p {
font-size: $font-size-base;
color: $text-gray;
margin-bottom: $spacing-xl;
}
.btn-create-app-large {
display: inline-flex;
align-items: center;
gap: $spacing-sm;
padding: 14px 32px;
background: $gradient-primary;
color: $white;
border: none;
border-radius: $border-radius-lg;
font-size: $font-size-md;
font-weight: $font-weight-semibold;
cursor: pointer;
transition: $transition-base;
box-shadow: $shadow-lg;
&:hover {
transform: translateY(-3px);
box-shadow: $shadow-xl;
}
&:active {
transform: translateY(-1px);
}
.btn-icon {
font-size: 20px;
}
}
}
// Bounce animation for empty state icon
@keyframes bounce {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
// Loading State (if needed)
.apikey-loading {
display: flex;
justify-content: center;
align-items: center;
padding: $spacing-5xl;
.spinner {
width: 48px;
height: 48px;
border: 4px solid $border-gray;
border-top-color: $primary-blue;
border-radius: $border-radius-circle;
animation: spin 1s linear infinite;
}
}
// -----------------------------------------------------------------------------
// API Key Registration Wizard Styles - Moved to _apikey-register.scss
// -----------------------------------------------------------------------------
// All registration-related styles have been moved to _apikey-register.scss
// This file now contains only API Key List styles
// Responsive adjustments for smaller screens
@media (max-width: $breakpoint-sm) {
.apikey-empty-state {
padding: $spacing-3xl $spacing-md;
.empty-icon {
font-size: 60px;
}
}
}
@@ -1,162 +0,0 @@
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kbank_apikey_layout}">
<body>
<section layout:fragment="contentFragment" class="content">
<div class="content_wrap">
<div class="sub_title3">
<h2 class="title">인증키 운영 전환 신청</h2>
</div>
<div class="inner i_cs h_inner5">
<div class="form_type">
<div class="info1 form_top">
<p class="title w_tit">
<span class="dot">API키 이름</span>
</p>
<div class="info_line info_add">
<div class="info_box1 w_inp4">
<input type="text" name="name" id="appRequestProdClientName" class="common_input_type_1 h_inp2" style="width: 300px;" placeholder="API키 이름 입력">
</div>
</div>
</div>
<div class="infor api_dot top" style="margin-top: 10px;">
<ul>
<li>
<p>인증키가 없을 경우, 승인 시, 인증키는 신규로 발급 됩니다.</p>
</li>
</ul>
</div>
</div>
<div class="form_type">
<div class="info1 form_top" style="margin-top: 10px;">
<p class="title w_tit">
<span class="">API키 선택</span>
</p>
<div class="info_line info_add">
<div class="info_box1 w_inp4">
<div class="custom_select select_w h_inp" style="width: 300px;">
<input type="hidden" id="clientId" />
<div class="select_selected h_inp" id="selectedDevApiKey">API키 선택</div>
<ul class="common_selecttype select_items select_hide" id="devApiKeyList">
</ul>
</div>
</div>
</div>
</div>
</div>
<p class="pop_text l_text m-only" style="margin-top: 10px; font-weight: 700;">API</p>
<div class="board_search sh_box top2">
<p class="pop_text l_text pc-only">API</p>
<div class="search_txt ser_api w_inp">
<input type="text" class="common_input_type_1 h_inp2" id="prod_api_keyword" placeholder="API 검색">
</div>
<div class="btn_wrap search_btn">
<button type="button" class="btn_search" id="prod_api_search">검색</button>
<i class="i_search"></i>
</div>
<p class="pop_text l_text pop_text2 pc-only" style="margin-left: 145px;">운영 이관 신청 API</p>
</div>
<div class="tb_conbox top2">
<div class="box table_type2 top_type">
<table class="table_col table_min tb_bdtop" id="prodSourceTable" cellspacing="0" cellpadding="0" width="100%">
<colgroup>
<col width="60px">
<col width="140px">
<col width="230px">
</colgroup>
<thead>
<tr>
<th>
<div class="total_wrap">
<span class="inp_check b_tit">
<input type="checkbox" class="agree_all" id="selectAllProdSourceTable" name="selectall" data-group="total">
<label for="selectAllProdSourceTable"></label>
</span>
</div>
</th>
<th>서비스</th>
<th>API</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
<div class="box pop_arrow m-only" style="text-align:center;">
<a href="#none" class="add_apis"><img th:src="@{/img/icon/Icon_arrow_btn_down.png}" alt="올리기"></a>
<a href="#none" class="remove_apis"><img th:src="@{/img/icon/Icon_arrow_btn_up.png}" alt="내리기"></a>
</div>
<div class="box pop_arrow pc-only" style="text-align:center;">
<p>
<a href="#none" class="add_apis"><img th:src="@{/img/icon/icon_arrow_btn_r.png}" alt="넣기"></a>
</p>
<p>
<a href="#none" class="remove_apis"><img th:src="@{/img/icon/icon_arrow_btn_l.png}" alt="빼기"></a>
</p>
</div>
<p class="pop_text l_text2 pt_top m-only">운영 이관 신청 API</p>
<div class="box table_type2 top_type">
<table class="table_col table_min tb_bdtop" id="prodTargetTable" cellspacing="0" cellpadding="0" width="100%">
<colgroup>
<col width="60px">
<col width="140px">
<col width="230px">
</colgroup>
<thead>
<tr>
<th>
<div class="total_wrap">
<span class="inp_check b_tit">
<input type="checkbox" class="agree_all" id="selectAllProdTargetTable" name="selectall2" data-group="total2">
<label for="selectAllProdTargetTable"></label>
</span>
</div>
</th>
<th>서비스</th>
<th>API</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
<p class="pop_text l_text2">신청사유</p>
<div class="pop_textbox">
<textarea name="reason" id="prod_reason" rows="5" cols="30" class="common_textareaType_1" placeholder="신청사유를 입력해 주세요."></textarea>
</div>
<div class="form_type">
<div class="btn_application btn_top3">
<button class="common_btn_type_1 gray btn_cancel">취소</button>
<div class="btn_gap"></div>
<button class="common_btn_type_1 request_prod_apis">운영 전환 신청</button>
</div>
</div>
</div>
</div>
</section>
<section layout:fragment="pagePopups" class="content pop">
<th:block th:replace="~{fragment/apikey-request-prod :: prodApiKeyRequestScript}"></th:block>
</section>
</body>
<th:block layout:fragment="contentScript">
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function () {
activateSelect();
$('.btn_cancel').on('click', function(){
window.location.href = '/apis';
});
});
</script>
</th:block>
</html>
@@ -1,92 +1,247 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kbank_apikey_layout}">
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kjbank_base_layout}">
<body>
<section layout:fragment="contentFragment" class="content">
<div class="content_wrap">
<div class="sub_title3">
<h2 class="title">인증키 목록</h2>
</div>
<div class="inner i_cs h_inner8">
<div class="tab-wrap">
<div class="tabs">
<ul class="tab_nav tab_bt">
<li class="active">
<a th:href="@{/myapikey}">개발</a>
</li>
<li>
<a th:href="@{/myapikey/api_key_prod_page}">운영</a>
</li>
</ul>
<div class="tab active">
<div class="tb_board_list">
<div class="table_box">
<table class="table table_min" cellspacing="0" cellpadding="0" width="100%">
<colgroup>
<col width="60px">
<col width="86px">
<col width="230px">
<col width="120px">
<col width="100px">
<col width="120px">
</colgroup>
<thead>
<tr>
<th>No</th>
<th>이름</th>
<th>인증키</th>
<th>인증키상태</th>
<th>사용 API 수</th>
<th>마지막수정일</th>
</tr>
</thead>
<tbody>
<tr th:each="apikey, status : ${apiKeys}">
<td th:text="${status.index + 1}">1</td>
<td>
<a th:href="@{/myapikey/api_key_detail(id=${apikey.clientid})}" th:text="${apikey.clientname}">수신</a>
</td>
<td>
<a th:href="@{/myapikey/api_key_detail(id=${apikey.clientid})}" th:text="${apikey.clientid}"></a>
</td>
<td th:text="${apikey.appstatus == '1' ? '활성화' : '비활성화'}">활성화</td>
<td th:text="${apikey.apiList.size()}">1</td>
<td th:text="${#temporals.format(apikey.modifiedon, 'yyyy.MM.dd')}"></td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="btn_wrap bt_location">
<button sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" type="button" class="btn_key" id="requestApiKey">인증키 신청</button>
</div>
<div class="pagination page_top" th:replace="~{fragment/pagination :: pagination(jsFunction='fn_select_page')}"></div>
<form name="listForm" th:action="@{/myapikey}" method="get">
<input type="hidden" id="page" name="page"/>
</form>
</div>
</div>
<th:block layout:fragment="contentFragment">
<section class="apikey-container">
<!-- Header Section -->
<div class="apikey-header">
<div class="apikey-title">
<h1>마이페이지</h1>
<h2>인증키 목록</h2>
</div>
<div class="apikey-actions">
<button sec:authorize="hasRole('ROLE_API_KEY_REQUEST')"
type="button"
class="btn-create-app"
id="requestApiKey">
<span class="btn-icon"></span>
앱 생성
</button>
</div>
</div>
</div>
</section>
<section sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" layout:fragment="pagePopups" class="content pop">
<div th:insert="~{fragment/apikey-request :: apiKeyRequest}" class="popup_total" id="apiKeyRequestPopup" style="display:none; z-index: 1000;"></div>
<th:block th:replace="~{fragment/apikey-request :: apiKeyRequestScript}"></th:block>
</section>
<th:block layout:fragment="contentScript">
<script>
document.addEventListener('DOMContentLoaded', function() {
activateSelect();
});
function fn_select_page(pageNo) {
document.listForm.page.value = pageNo;
document.listForm.action = '[[@{/myapikey}]]';
document.listForm.submit();
}
<!-- Result Count -->
<div class="apikey-result-count">
<strong th:text="${(appRequests != null ? appRequests.size() : 0) + (apiKeys != null ? apiKeys.size() : 0)}">0</strong>개의 인증키
</div>
<!-- App Requests Cards Grid (Pending) -->
<div class="apikey-card-grid" th:if="${appRequests != null and !appRequests.isEmpty()}">
<a class="apikey-card"
th:each="request : ${appRequests}"
th:href="@{/myapikey/app_request_detail(id=${request.id})}">
<!-- App Icon -->
<div class="apikey-card-icon">
<img th:if="${request.appIconFileId != null}"
th:src="@{/file/download(fileSn=1,fileId=${request.appIconFileId})}"
alt="App Icon"
class="app-icon-image">
<div th:unless="${request.appIconFileId != null}" class="app-icon-placeholder">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</svg>
</div>
</div>
<!-- App Content -->
<div class="apikey-card-content">
<!-- App Name -->
<h3 class="apikey-card-title" th:text="${request.clientName}">앱 이름</h3>
<!-- App Description -->
<p class="apikey-card-description"
th:text="${request.appDescription != null ? request.appDescription : '설명 없음'}">
앱 설명이 여기에 표시됩니다.
</p>
</div>
<!-- App Status Badge (Using Approval State) -->
<div class="apikey-card-status-wrapper">
<span class="apikey-card-status status-pending"
th:text="${request.approval != null and request.approval.approvalStatus != null ? request.approval.approvalStatus.description : '상태 없음'}">
승인 대기
</span>
</div>
</a>
</div>
<!-- API Key Cards Grid (Approved) -->
<div class="apikey-card-grid" th:if="${apiKeys != null and !apiKeys.isEmpty()}">
<a class="apikey-card"
th:each="apikey, status : ${apiKeys}"
th:href="@{/myapikey/credential_detail(id=${apikey.clientid})}">
<!-- App Icon -->
<div class="apikey-card-icon">
<img th:if="${apikey.appIconFileId != null}"
th:src="@{/file/download(fileSn=1,fileId=${apikey.appIconFileId})}"
alt="App Icon"
class="app-icon-image">
<div th:unless="${apikey.appIconFileId != null}" class="app-icon-placeholder">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</svg>
</div>
</div>
<!-- App Content -->
<div class="apikey-card-content">
<!-- App Name -->
<h3 class="apikey-card-title" th:text="${apikey.clientname}">앱 이름</h3>
<!-- App Description -->
<p class="apikey-card-description"
th:text="${apikey.appDescription != null ? apikey.appDescription : '설명 없음'}">
앱 설명이 여기에 표시됩니다.
</p>
</div>
<!-- App Status Badge -->
<div class="apikey-card-status-wrapper">
<span class="apikey-card-status"
th:classappend="${apikey.appstatus == '1' ? 'status-active' : apikey.appstatus == '0' ? 'status-inactive' : 'status-pending'}"
th:text="${apikey.appstatus == '1' ? '활성화' : apikey.appstatus == '0' ? '비활성화' : '승인 대기'}">
활성화
</span>
</div>
</a>
</div>
<!-- Empty State -->
<div class="apikey-empty-state" th:if="${(appRequests == null or appRequests.isEmpty()) and (apiKeys == null or apiKeys.isEmpty())}">
<div class="empty-icon">🔑</div>
<h3>등록된 인증키가 없습니다</h3>
<p>새로운 앱을 생성하여 API를 사용해보세요.</p>
<button sec:authorize="hasRole('ROLE_API_KEY_REQUEST')"
type="button"
class="btn-create-app-large"
id="requestApiKeyEmpty">
<span class="btn-icon"></span>
첫 앱 생성하기
</button>
</div>
</section>
</th:block>
<th:block layout:fragment="contentScript">
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function() {
// Card click handlers
const apikeyCards = document.querySelectorAll('.apikey-card');
apikeyCards.forEach(function(card) {
// Prevent navigation when clicking on specific elements
const preventNavElements = card.querySelectorAll('.btn-copy-key, .btn-view-detail, a');
preventNavElements.forEach(function(element) {
element.addEventListener('click', function(e) {
e.stopPropagation();
});
});
// Card click handler
card.addEventListener('click', function(e) {
// Don't navigate if clicking on interactive elements
if (e.target.closest('.btn-copy-key') || e.target.closest('.btn-view-detail')) {
return;
}
const href = this.getAttribute('data-href');
if (href) {
window.location.href = href;
}
});
// Keyboard accessibility
card.addEventListener('keypress', function(e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
const href = this.getAttribute('data-href');
if (href) {
window.location.href = href;
}
}
});
});
// Copy to clipboard functionality
const copyButtons = document.querySelectorAll('.btn-copy-key');
copyButtons.forEach(function(button) {
button.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
const textToCopy = this.getAttribute('data-clipboard-text');
// Create temporary input element
const tempInput = document.createElement('input');
tempInput.value = textToCopy;
document.body.appendChild(tempInput);
tempInput.select();
try {
document.execCommand('copy');
// Visual feedback
const originalContent = this.innerHTML;
this.innerHTML = '<span class="copy-icon"></span>';
this.classList.add('copied');
setTimeout(() => {
this.innerHTML = originalContent;
this.classList.remove('copied');
}, 2000);
} catch (err) {
console.error('Failed to copy text: ', err);
}
document.body.removeChild(tempInput);
});
});
// App creation button handlers
const requestApiKeyBtn = document.getElementById('requestApiKey');
const requestApiKeyEmptyBtn = document.getElementById('requestApiKeyEmpty');
function handleApiKeyRequest() {
// Clear any existing registration data from sessionStorage
const keysToRemove = [];
for (let i = 0; i < sessionStorage.length; i++) {
const key = sessionStorage.key(i);
if (key && key.startsWith('registration_')) {
keysToRemove.push(key);
}
}
keysToRemove.forEach(key => sessionStorage.removeItem(key));
// Redirect to the API key registration wizard with clear parameter
// This tells the server to clear the session and start fresh
window.location.href = '/myapikey/register/step1?clear=true';
}
if (requestApiKeyBtn) {
requestApiKeyBtn.addEventListener('click', handleApiKeyRequest);
}
if (requestApiKeyEmptyBtn) {
requestApiKeyEmptyBtn.addEventListener('click', handleApiKeyRequest);
}
});
</script>
</th:block>
</body>
@@ -0,0 +1,377 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kjbank_base_layout}">
<body>
<th:block layout:fragment="contentFragment">
<section class="apikey-register-container">
<!-- Progress Indicator -->
<div class="register-progress">
<div class="progress-steps">
<div class="progress-step active">
<div class="step-number">1</div>
<div class="step-label">기본 정보</div>
</div>
<div class="progress-line"></div>
<div class="progress-step">
<div class="step-number">2</div>
<div class="step-label">API 선택</div>
</div>
<div class="progress-line"></div>
<div class="progress-step">
<div class="step-number">3</div>
<div class="step-label">완료</div>
</div>
</div>
</div>
<!-- Header -->
<div class="register-header">
<h1>API Key 수정</h1>
<h2>Step 1: 기본 정보 수정</h2>
<p class="header-description">
앱의 기본 정보를 수정해주세요. 변경 사항은 승인 후 적용됩니다.
</p>
<!-- Error Message -->
<div th:if="${error}" class="alert alert-error" style="margin-top: 20px;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
<span th:text="${error}"></span>
</div>
</div>
<!-- Form Content -->
<div class="register-content">
<form id="modifyStep1Form" method="post" th:action="@{/myapikey/modify/step1}" th:object="${apiKeyModification}" class="register-form" enctype="multipart/form-data">
<!-- Hidden field for clientId -->
<input type="hidden" th:field="*{clientId}"/>
<div class="form-card">
<!-- App Icon -->
<div class="form-group">
<label for="appIcon" class="form-label required">
앱 아이콘
<span class="required-mark">*</span>
</label>
<div class="icon-upload-wrapper">
<div class="icon-preview" id="iconPreview">
<div class="icon-placeholder"
th:if="${apiKeyModification.appIconData == null and apiKeyModification.appIconFileId == null}">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
<span>이미지 선택</span>
</div>
<!-- Show image from file service if available -->
<img id="iconPreviewImage"
th:if="${apiKeyModification.appIconFileId != null}"
th:src="@{/file/download(fileSn=1,fileId=${apiKeyModification.appIconFileId})}"
alt="Icon Preview">
<!-- Show image from data if available (newly uploaded) -->
<img id="iconPreviewImage"
th:if="${apiKeyModification.appIconData != null and apiKeyModification.appIconFileId == null}"
th:src="'data:' + ${apiKeyModification.appIconContentType} + ';base64,' + ${T(java.util.Base64).getEncoder().encodeToString(apiKeyModification.appIconData)}"
alt="Icon Preview">
<button type="button" class="btn-remove-icon" id="btnRemoveIcon"
th:style="${(apiKeyModification.appIconData != null or apiKeyModification.appIconFileId != null) ? 'display: block;' : 'display: none;'}"
onclick="removeAppIcon()" title="아이콘 삭제">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<input type="file"
id="appIcon"
name="appIcon"
class="icon-input"
accept="image/png,image/jpeg,image/jpg">
<button type="button" class="btn-icon-select" onclick="document.getElementById('appIcon').click()">
파일 선택
</button>
</div>
<small class="form-hint">PNG, JPG 형식 (최대 2MB, 권장 크기: 512x512px)</small>
</div>
<!-- App Name -->
<div class="form-group">
<label for="appName" class="form-label required">
앱 이름
<span class="required-mark">*</span>
</label>
<input type="text"
id="appName"
th:field="*{appName}"
class="form-control"
placeholder="예: My Application"
maxlength="50"
required>
<small class="form-hint">앱을 구별할 수 있는 고유한 이름을 입력해주세요. (최대 50자)</small>
</div>
<!-- App Description -->
<div class="form-group">
<label for="appDescription" class="form-label">앱 설명</label>
<textarea id="appDescription"
th:field="*{appDescription}"
class="form-control"
rows="4"
placeholder="앱의 주요 기능과 용도를 간단히 설명해주세요."
maxlength="500"></textarea>
<small class="form-hint">앱의 용도와 주요 기능을 설명해주세요. (최대 500자)</small>
</div>
<!-- Callback URL -->
<div class="form-group">
<label for="callbackUrl" class="form-label">Callback URL</label>
<input type="url"
id="callbackUrl"
th:field="*{callbackUrl}"
class="form-control"
placeholder="https://example.com/callback">
<small class="form-hint">OAuth 2.0 인증 시 사용할 콜백 URL을 입력해주세요.</small>
</div>
<!-- IP Whitelist -->
<div class="form-group">
<label for="ipWhitelist" class="form-label">
IP 화이트리스트
</label>
<div class="ip-input-group">
<input type="text"
id="ipWhitelistInput"
class="form-input"
placeholder="예: 192.168.1.1 또는 10.0.0.0/24">
<button type="button" class="btn-add-ip" onclick="addIpAddress()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
추가
</button>
</div>
<small class="form-hint">허용할 IP 주소 또는 CIDR 대역을 입력하세요. (선택사항)</small>
<!-- IP List Display -->
<div class="ip-list" id="ipList" style="display: none;">
<div class="ip-list-header">
<span class="ip-count">등록된 IP: <strong id="ipCount">0</strong></span>
</div>
<div class="ip-items" id="ipItems">
<!-- Dynamically added IP items -->
</div>
</div>
<!-- Hidden input to store IP list -->
<input type="hidden" id="ipWhitelist" name="ipWhitelist" value="">
</div>
</div>
<!-- Action Buttons -->
<div class="form-actions">
<a th:href="@{/myapikey/modify/cancel(clientId=${apiKeyModification.clientId})}" class="btn-cancel">
취소
</a>
<button type="submit" class="btn-primary">
다음 단계
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
</div>
</form>
</div>
</section>
</th:block>
<th:block layout:fragment="contentScript">
<script th:inline="javascript">
/*<![CDATA[*/
// IP address list management
let ipAddresses = [];
// Remove app icon
function removeAppIcon() {
const appIconInput = document.getElementById('appIcon');
const iconPreviewImage = document.getElementById('iconPreviewImage');
const iconPlaceholder = document.querySelector('.icon-placeholder');
const btnRemoveIcon = document.getElementById('btnRemoveIcon');
// Clear file input
appIconInput.value = '';
// Hide preview image and show placeholder
iconPreviewImage.style.display = 'none';
iconPreviewImage.src = '';
iconPlaceholder.style.display = 'flex';
// Hide delete button
btnRemoveIcon.style.display = 'none';
}
// Add IP address to the list
function addIpAddress() {
const ipInput = document.getElementById('ipWhitelistInput');
const ipValue = ipInput.value.trim();
// Validate IP format
if (!ipValue) {
alert('IP 주소를 입력해주세요.');
return;
}
// Basic IP validation (supports IP and CIDR notation)
const ipPattern = /^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$/;
if (!ipPattern.test(ipValue)) {
alert('올바른 IP 주소 형식이 아닙니다.\n예: 192.168.1.1 또는 10.0.0.0/24');
return;
}
// Check for duplicates
if (ipAddresses.includes(ipValue)) {
alert('이미 추가된 IP 주소입니다.');
return;
}
// Add to array
ipAddresses.push(ipValue);
// Update UI
updateIpList();
// Clear input
ipInput.value = '';
ipInput.focus();
}
// Remove IP address from the list
function removeIpAddress(ip) {
const index = ipAddresses.indexOf(ip);
if (index > -1) {
ipAddresses.splice(index, 1);
updateIpList();
}
}
// Update IP list display
function updateIpList() {
const ipList = document.getElementById('ipList');
const ipItems = document.getElementById('ipItems');
const ipCount = document.getElementById('ipCount');
const ipWhitelistHidden = document.getElementById('ipWhitelist');
// Update count
ipCount.textContent = ipAddresses.length;
// Show/hide list container
if (ipAddresses.length > 0) {
ipList.style.display = 'block';
// Build IP items HTML
let itemsHTML = '';
ipAddresses.forEach(function(ip) {
itemsHTML += `
<div class="ip-item">
<span class="ip-address">${ip}</span>
<button type="button" class="btn-remove-ip" onclick="removeIpAddress('${ip}')" title="제거">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
`;
});
ipItems.innerHTML = itemsHTML;
} else {
ipList.style.display = 'none';
ipItems.innerHTML = '';
}
// Update hidden input with comma-separated values
ipWhitelistHidden.value = ipAddresses.join(',');
}
$(document).ready(function() {
// Initialize with existing icon data
var hasExistingIcon = /*[[${apiKeyModification.appIconData != null}]]*/ false;
var hasExistingIconFile = /*[[${apiKeyModification.appIconFileId != null}]]*/ false;
// If we have existing icon data, it's already shown by Thymeleaf
// But we need to ensure the UI state is correct
if (hasExistingIcon || hasExistingIconFile) {
$('.icon-placeholder').hide();
$('#btnRemoveIcon').show();
}
// Restore existing IP whitelist
const sessionIpWhitelist = /*[[${apiKeyModification.ipWhitelistAsString}]]*/ '';
if (sessionIpWhitelist && sessionIpWhitelist.trim() !== '') {
const ips = sessionIpWhitelist.split(',');
ips.forEach(function(ip) {
const trimmedIp = ip.trim();
if (trimmedIp && !ipAddresses.includes(trimmedIp)) {
ipAddresses.push(trimmedIp);
}
});
updateIpList();
}
// File upload preview
$('#appIcon').on('change', function(e) {
var file = e.target.files[0];
const btnRemoveIcon = document.getElementById('btnRemoveIcon');
const iconPreviewImage = document.getElementById('iconPreviewImage');
const iconPlaceholder = document.querySelector('.icon-placeholder');
if (file) {
if (file.size > 2 * 1024 * 1024) {
alert('파일 크기는 2MB를 초과할 수 없습니다.');
$(this).val('');
return;
}
var reader = new FileReader();
reader.onload = function(e) {
iconPreviewImage.src = e.target.result;
iconPreviewImage.style.display = 'block';
iconPlaceholder.style.display = 'none';
btnRemoveIcon.style.display = 'flex';
};
reader.readAsDataURL(file);
}
});
// IP whitelist input - Enter key handler
$('#ipWhitelistInput').on('keypress', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
addIpAddress();
}
});
// Form submission - ensure IP list is updated
$('#modifyStep1Form').on('submit', function(e) {
// IP whitelist is already updated via updateIpList()
});
});
/*]]>*/
</script>
</th:block>
</body>
</html>
@@ -0,0 +1,718 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kjbank_base_layout}">
<body>
<th:block layout:fragment="contentFragment">
<!-- Progress Indicator -->
<div class="register-progress">
<div class="progress-steps">
<div class="progress-step completed">
<div class="step-number"></div>
<div class="step-label">기본 정보</div>
</div>
<div class="progress-line filled"></div>
<div class="progress-step active">
<div class="step-number">2</div>
<div class="step-label">API 선택</div>
</div>
<div class="progress-line"></div>
<div class="progress-step">
<div class="step-number">3</div>
<div class="step-label">완료</div>
</div>
</div>
</div>
<!-- Header -->
<div class="register-header">
<h1>API Key 수정</h1>
<h2>Step 2: API 선택</h2>
<p class="header-description">
사용할 API를 선택해주세요. 여러 개의 API를 선택할 수 있습니다.
</p>
<!-- Error Message -->
<div th:if="${error}" class="alert alert-error" style="margin-top: 20px;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
<span th:text="${error}"></span>
</div>
</div>
<!-- Main Container with Sidebar Layout -->
<section class="apikey-register-container with-sidebar">
<!-- Sidebar Navigation -->
<aside class="apikey-register-sidebar" id="apiSidebar">
<nav class="apikey-sidebar-nav">
<!-- All APIs -->
<div class="menu-section">
<div class="menu-title active" data-group="">
전체
<span class="api-count" id="totalApiCount">0</span>
</div>
</div>
<!-- Service Categories -->
<div class="menu-section" th:each="service : ${apiServices}">
<div class="menu-title"
th:data-group="${service.id}">
[[${service.groupName}]]
<span class="api-count" th:text="${service.apiGroupApiList.size()}">0</span>
</div>
</div>
</nav>
</aside>
<!-- Mobile Menu Toggle -->
<button class="apikey-mobile-toggle" id="mobileToggle" aria-label="메뉴 열기">
</button>
<!-- Mobile Overlay -->
<div class="apikey-mobile-overlay" id="mobileOverlay"></div>
<!-- Main Content -->
<main class="apikey-register-content">
<form id="modifyStep2Form" method="post" th:action="@{/myapikey/modify/step2}" th:object="${apiKeyModification}" class="register-form">
<!-- Hidden field for clientId -->
<input type="hidden" th:field="*{clientId}"/>
<!-- Search Box with Select All -->
<div class="api-filter-header">
<div class="select-all-wrapper" id="selectAllWrapper" style="display: none;">
<label class="select-all-label">
<input type="checkbox" id="selectAllCheckbox" class="select-all-checkbox">
<span id="selectAllText">전체 선택</span>
</label>
</div>
<div class="api-search-box">
<input type="text"
id="apiSearch"
class="search-input"
placeholder="API 검색...">
<span class="search-icon">🔍</span>
</div>
</div>
<!-- Result Count -->
<div class="api-result-count">
<strong id="visibleCount">0</strong>
</div>
<!-- API Cards Grid -->
<div class="api-selection-card-grid" id="apiCardGrid">
<!-- Loading state -->
<div class="loading-state" id="loadingState" style="grid-column: 1 / -1;">
<div class="loading-spinner"></div>
<p>API 목록을 불러오는 중...</p>
</div>
<!-- Empty state (initially hidden) -->
<div class="empty-api-state" id="emptyState" style="display: none; grid-column: 1 / -1;">
<div class="empty-icon">📦</div>
<h3>API를 선택해주세요</h3>
<p>왼쪽 메뉴에서 서비스를 선택하면 해당 API 목록이 표시됩니다.</p>
</div>
<!-- API cards will be dynamically loaded here -->
</div>
<!-- Form Actions -->
<div class="form-actions">
<!-- Save and go back button -->
<button type="submit" formaction="/myapikey/modify/step2/save" class="btn-secondary" id="btnPrevStep">
<span class="icon"></span>
이전 단계
</button>
<button type="submit" class="btn-primary" id="btnNext">
저장
<span class="icon"></span>
</button>
</div>
</form>
</main>
</section>
<!-- Floating Cart Button -->
<button type="button" class="floating-cart-btn" id="floatingCartBtn" style="display: none;">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="9" cy="21" r="1"></circle>
<circle cx="20" cy="21" r="1"></circle>
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"></path>
</svg>
<span class="cart-text">선택된 API:</span>
<span class="cart-badge" id="cartBadge">0</span>
</button>
<!-- Selected APIs Modal -->
<div class="selected-apis-modal" id="selectedApisModal" style="display: none;">
<div class="modal-backdrop" id="modalOverlay"></div>
<div class="modal-dialog">
<div class="modal-header">
<h3 class="modal-title">선택된 API 목록</h3>
<button type="button" class="modal-close" id="modalCloseBtn">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="modal-body">
<div class="selected-apis-list" id="modalSelectedList">
<!-- Dynamically populated -->
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" id="modalCancelBtn">닫기</button>
</div>
</div>
</div>
</th:block>
<th:block layout:fragment="contentScript">
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function() {
// DOM Elements
const form = document.getElementById('modifyStep2Form');
const searchInput = document.getElementById('apiSearch');
const sidebar = document.getElementById('apiSidebar');
const mobileToggle = document.getElementById('mobileToggle');
const mobileOverlay = document.getElementById('mobileOverlay');
const menuTitles = document.querySelectorAll('.menu-title');
const visibleCount = document.getElementById('visibleCount');
const apiCardGrid = document.getElementById('apiCardGrid');
const loadingState = document.getElementById('loadingState');
const emptyState = document.getElementById('emptyState');
const totalApiCount = document.getElementById('totalApiCount');
let currentFilter = ''; // Empty string means "all"
let currentServiceName = '전체'; // Store current service name
let allApis = []; // Store loaded APIs
let allLoadedApis = []; // Store ALL APIs from server for modal display
let selectedApis = new Set(); // Track selected API IDs
// Restore selected APIs from session (for modification)
const sessionSelectedApis = /*[[${apiKeyModification.selectedApis}]]*/ [];
if (sessionSelectedApis && Array.isArray(sessionSelectedApis)) {
sessionSelectedApis.forEach(function(apiId) {
selectedApis.add(apiId);
});
}
// Load all APIs for modal display (used when APIs from different categories are selected)
function loadAllApisForModal() {
fetch('/apis/for_request')
.then(response => response.json())
.then(apis => {
allLoadedApis = apis;
console.log('Loaded', allLoadedApis.length, 'APIs for modal display');
})
.catch(error => {
console.error('Failed to load all APIs for modal:', error);
});
}
// Load APIs via AJAX
function loadApis(groupId) {
// Show loading state
loadingState.style.display = 'block';
emptyState.style.display = 'none';
// Remove existing API cards
document.querySelectorAll('.api-selection-card').forEach(card => card.remove());
// Build URL with optional groupId filter
let url = '/apis/for_request';
if (groupId) {
url += '?groupIds=' + encodeURIComponent(groupId);
}
fetch(url)
.then(response => response.json())
.then(apis => {
allApis = apis;
loadingState.style.display = 'none';
if (apis.length === 0) {
emptyState.style.display = 'block';
visibleCount.textContent = '0';
return;
}
renderApiCards(apis);
visibleCount.textContent = apis.length;
// Update total count
if (!groupId || groupId === '') {
totalApiCount.textContent = apis.length;
}
// Update select all UI
updateSelectAllUI();
})
.catch(error => {
console.error('Failed to load APIs:', error);
loadingState.style.display = 'none';
emptyState.querySelector('h3').textContent = 'API 로드 실패';
emptyState.querySelector('p').textContent = '다시 시도해주세요.';
emptyState.style.display = 'block';
});
}
// Update select all UI visibility and label
function updateSelectAllUI() {
const selectAllWrapper = document.getElementById('selectAllWrapper');
const selectAllText = document.getElementById('selectAllText');
if (currentFilter === '') {
// Hide select all when "전체" is selected
selectAllWrapper.style.display = 'none';
} else {
// Show select all with service name
selectAllWrapper.style.display = 'flex';
selectAllText.textContent = currentServiceName + ' API 전체 선택';
}
updateSelectAllCheckboxState();
}
// Update select all checkbox state (checked/indeterminate/unchecked)
function updateSelectAllCheckboxState() {
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
const visibleCards = Array.from(document.querySelectorAll('.api-selection-card'))
.filter(card => card.style.display !== 'none');
if (visibleCards.length === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
return;
}
const visibleCheckboxes = visibleCards.map(card => card.querySelector('.api-checkbox'));
const checkedCount = visibleCheckboxes.filter(cb => cb.checked).length;
if (checkedCount === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
} else if (checkedCount === visibleCheckboxes.length) {
selectAllCheckbox.checked = true;
selectAllCheckbox.indeterminate = false;
} else {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = true;
}
}
// Render API cards
function renderApiCards(apis) {
const fragment = document.createDocumentFragment();
apis.forEach(api => {
const card = createApiCard(api);
fragment.appendChild(card);
});
apiCardGrid.appendChild(fragment);
// Re-attach event listeners
attachCardEventListeners();
// Update modal list after cards are rendered
updateModalList();
}
// Create API card element
function createApiCard(api) {
const card = document.createElement('div');
card.className = 'api-selection-card';
// Note: AJAX response doesn't have apiGroupId, use apiGroupName or service for grouping
card.setAttribute('data-group', api.apiGroupName || '');
card.setAttribute('data-name', (api.apiName || '').toLowerCase());
card.setAttribute('data-desc', (api.apiSimpleDescription || '').toLowerCase());
card.setAttribute('data-api-id', api.apiId);
const isSelected = selectedApis.has(api.apiId);
if (isSelected) {
card.classList.add('selected');
}
card.innerHTML = `
<div class="checkbox-wrapper">
<input type="checkbox"
name="selectedApis"
value="${api.apiId}"
id="api-${api.apiId}"
class="api-checkbox"
${isSelected ? 'checked' : ''}>
</div>
<label class="api-card-content" for="api-${api.apiId}">
<div class="api-card-header">
<span class="api-card-badge">${api.service || '카테고리'}</span>
<span class="api-method">${api.apiMethod || 'GET'}</span>
</div>
<h3 class="api-name">${api.apiName || 'API 이름'}</h3>
<p class="api-description">${api.apiSimpleDescription || 'API 설명이 없습니다.'}</p>
<div class="api-endpoint">
<code>${api.apiUrl || '/api/v1/example'}</code>
</div>
</label>
`;
return card;
}
// Attach event listeners to cards
function attachCardEventListeners() {
const checkboxes = document.querySelectorAll('.api-checkbox');
const apiCards = document.querySelectorAll('.api-selection-card');
checkboxes.forEach(checkbox => {
checkbox.addEventListener('change', function() {
updateCardSelection(this);
updateSelectedCount();
updateSelectAllCheckboxState();
});
});
apiCards.forEach(card => {
card.addEventListener('click', function(e) {
if (e.target.classList.contains('api-checkbox') || e.target.tagName === 'LABEL') {
return;
}
const checkbox = card.querySelector('.api-checkbox');
if (checkbox) {
checkbox.checked = !checkbox.checked;
updateCardSelection(checkbox);
updateSelectedCount();
updateSelectAllCheckboxState();
}
});
});
}
// Update card visual state
function updateCardSelection(checkbox) {
const card = checkbox.closest('.api-selection-card');
if (checkbox.checked) {
card.classList.add('selected');
selectedApis.add(checkbox.value);
} else {
card.classList.remove('selected');
selectedApis.delete(checkbox.value);
}
}
// Update selected count
function updateSelectedCount() {
// Update floating cart button
const floatingCartBtn = document.getElementById('floatingCartBtn');
const cartBadge = document.getElementById('cartBadge');
if (selectedApis.size > 0) {
floatingCartBtn.style.display = 'flex';
cartBadge.textContent = selectedApis.size;
} else {
floatingCartBtn.style.display = 'none';
}
// Update modal list
updateModalList();
}
// Update modal selected APIs list
function updateModalList() {
const modalSelectedList = document.getElementById('modalSelectedList');
modalSelectedList.innerHTML = '';
if (selectedApis.size === 0) {
modalSelectedList.innerHTML = '<p class="empty-message">선택된 API가 없습니다.</p>';
return;
}
// Build list using selectedApis Set for consistency
// This ensures we show all selected APIs regardless of current filter/view
console.log('updateModalList: Building modal for', selectedApis.size, 'selected APIs');
selectedApis.forEach(function(apiId) {
// Try to find the checkbox in the DOM first
const checkbox = document.querySelector('.api-checkbox[value="' + apiId + '"]');
let apiName = apiId; // Default to ID if we can't find the name
if (checkbox) {
// If checkbox exists in DOM, get the name from the card
const card = checkbox.closest('.api-selection-card');
if (card) {
const nameElement = card.querySelector('.api-name');
if (nameElement) {
apiName = nameElement.textContent;
}
}
} else {
// If checkbox not in DOM (different category is showing),
// try to find the API in our loaded data
let api = allApis.find(a => a.apiId === apiId);
if (!api && allLoadedApis.length > 0) {
api = allLoadedApis.find(a => a.apiId === apiId);
}
if (api && api.apiName) {
apiName = api.apiName;
}
}
const apiPill = document.createElement('div');
apiPill.className = 'api-pill';
apiPill.innerHTML = `
<span class="api-pill-name">${apiName}</span>
<button type="button" class="api-pill-remove" data-value="${apiId}" aria-label="Remove ${apiName}">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
`;
console.log('Adding pill for:', apiId, '→', apiName);
modalSelectedList.appendChild(apiPill);
});
// Add remove handlers to newly created elements
modalSelectedList.querySelectorAll('.api-pill-remove').forEach(function(btn) {
btn.addEventListener('click', function() {
const value = this.getAttribute('data-value');
const checkbox = document.querySelector('.api-checkbox[value="' + value + '"]');
if (checkbox) {
checkbox.checked = false;
updateCardSelection(checkbox);
updateSelectedCount();
} else {
// If checkbox not found (maybe different category is showing),
// still remove from selection
selectedApis.delete(value);
updateSelectedCount();
}
});
});
}
// Search functionality
if (searchInput) {
searchInput.addEventListener('input', function() {
const searchTerm = this.value.toLowerCase();
let visibleCountNum = 0;
const apiCards = document.querySelectorAll('.api-selection-card');
apiCards.forEach(function(card) {
const apiName = card.getAttribute('data-name');
const apiDesc = card.getAttribute('data-desc');
// Check if matches search
const matchesSearch = apiName.includes(searchTerm) || apiDesc.includes(searchTerm);
if (matchesSearch) {
card.style.display = '';
visibleCountNum++;
} else {
card.style.display = 'none';
}
});
visibleCount.textContent = visibleCountNum;
updateSelectAllCheckboxState();
});
}
// Sidebar category selection
menuTitles.forEach(function(title) {
title.addEventListener('click', function(e) {
e.preventDefault();
// Remove active from all
menuTitles.forEach(t => t.classList.remove('active'));
// Add active to clicked
this.classList.add('active');
const groupId = this.getAttribute('data-group');
currentFilter = groupId;
// Store current service name (extract text before the count)
currentServiceName = this.textContent.trim().split('\n')[0].trim();
// Load APIs for selected service
loadApis(groupId);
// Clear search when changing category
if (searchInput) {
searchInput.value = '';
}
// Close mobile menu if open
if (window.innerWidth <= 768 && sidebar.classList.contains('mobile-open')) {
closeMobileMenu();
}
});
});
// Mobile menu toggle
function closeMobileMenu() {
sidebar.classList.remove('mobile-open');
mobileOverlay.classList.remove('active');
mobileToggle.innerHTML = '☰';
mobileToggle.setAttribute('aria-label', '메뉴 열기');
}
if (mobileToggle && sidebar && mobileOverlay) {
mobileToggle.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
sidebar.classList.toggle('mobile-open');
mobileOverlay.classList.toggle('active');
if (sidebar.classList.contains('mobile-open')) {
this.innerHTML = '✕';
this.setAttribute('aria-label', '메뉴 닫기');
} else {
this.innerHTML = '☰';
this.setAttribute('aria-label', '메뉴 열기');
}
});
// Close menu on overlay click
mobileOverlay.addEventListener('click', function() {
closeMobileMenu();
});
}
// Close mobile menu on resize to desktop
window.addEventListener('resize', function() {
if (window.innerWidth > 768 && sidebar.classList.contains('mobile-open')) {
closeMobileMenu();
}
});
// Modal control
const floatingCartBtn = document.getElementById('floatingCartBtn');
const selectedApisModal = document.getElementById('selectedApisModal');
const modalOverlay = document.getElementById('modalOverlay');
const modalCloseBtn = document.getElementById('modalCloseBtn');
const modalCancelBtn = document.getElementById('modalCancelBtn');
// Open modal
function openModal() {
selectedApisModal.style.display = 'block';
// Add show class to backdrop and modal for visibility
setTimeout(function() {
if (modalOverlay) {
modalOverlay.classList.add('show');
}
selectedApisModal.classList.add('show');
}, 10);
document.body.style.overflow = 'hidden'; // Prevent background scroll
}
// Close modal
function closeModal() {
// Remove show class first for transition
if (modalOverlay) {
modalOverlay.classList.remove('show');
}
selectedApisModal.classList.remove('show');
// Hide modal after transition
setTimeout(function() {
selectedApisModal.style.display = 'none';
}, 300);
document.body.style.overflow = ''; // Restore scroll
}
// Floating cart button click
if (floatingCartBtn) {
floatingCartBtn.addEventListener('click', openModal);
}
// Modal overlay click
if (modalOverlay) {
modalOverlay.addEventListener('click', closeModal);
}
// Modal close button click
if (modalCloseBtn) {
modalCloseBtn.addEventListener('click', closeModal);
}
// Modal cancel button click
if (modalCancelBtn) {
modalCancelBtn.addEventListener('click', closeModal);
}
// Close modal on ESC key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && selectedApisModal.style.display === 'block') {
closeModal();
}
});
// Select all checkbox event handler
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
if (selectAllCheckbox) {
selectAllCheckbox.addEventListener('change', function() {
const isChecked = this.checked;
const visibleCards = Array.from(document.querySelectorAll('.api-selection-card'))
.filter(card => card.style.display !== 'none');
visibleCards.forEach(function(card) {
const checkbox = card.querySelector('.api-checkbox');
if (checkbox) {
checkbox.checked = isChecked;
updateCardSelection(checkbox);
}
});
updateSelectedCount();
});
}
// Form validation
form.addEventListener('submit', function(e) {
// Allow submission even without selected APIs (user might want to remove all)
// But show confirmation
if (selectedApis.size === 0) {
if (!confirm('API를 선택하지 않으셨습니다. 계속하시겠습니까?')) {
e.preventDefault();
return false;
}
}
});
// Initialize
// Load all APIs for modal display FIRST (async)
loadAllApisForModal();
// Load all APIs automatically on page load
loadApis('');
// Update count after APIs are loaded (this will show count from restored session data)
updateSelectedCount();
// Log restored selections for debugging
if (selectedApis.size > 0) {
console.log('Restored ' + selectedApis.size + ' selected APIs from session:', Array.from(selectedApis));
}
});
</script>
</th:block>
</body>
</html>
@@ -0,0 +1,152 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kjbank_base_layout}">
<body>
<th:block layout:fragment="contentFragment">
<section class="apikey-register-container">
<!-- Progress Indicator -->
<div class="register-progress">
<div class="progress-steps">
<div class="progress-step completed">
<div class="step-number"></div>
<div class="step-label">기본 정보</div>
</div>
<div class="progress-line filled"></div>
<div class="progress-step completed">
<div class="step-number"></div>
<div class="step-label">API 선택</div>
</div>
<div class="progress-line filled"></div>
<div class="progress-step active completed">
<div class="step-number"></div>
<div class="step-label">완료</div>
</div>
</div>
</div>
<!-- Success Result -->
<div class="register-result success" th:if="${modificationComplete}">
<!-- Success Icon Animation -->
<div class="result-icon-wrapper">
<div class="result-icon success-icon">
<svg class="checkmark" viewBox="0 0 52 52">
<circle class="checkmark-circle" cx="26" cy="26" r="25" fill="none"/>
<path class="checkmark-check" fill="none" d="M14.1 27.2l7.1 7.2 16.7-16.8"/>
</svg>
</div>
</div>
<!-- Success Message -->
<div class="result-header">
<h1>API Key 수정 요청 완료!</h1>
<p class="result-description">
API Key 수정 요청이 성공적으로 접수되었습니다. 관리자 승인 후 변경 사항이 적용됩니다.
</p>
</div>
<!-- Result Content -->
<div class="result-content">
<!-- Action Buttons -->
<div class="result-actions">
<a th:href="@{/myapikey}" class="btn-secondary">
<span class="icon">📋</span>
API Key 목록으로
</a>
<a th:href="@{/myapikey/api_key_request/history}" class="btn-primary">
<span class="icon">📄</span>
요청 내역 확인
</a>
</div>
</div>
</div>
<!-- Error Result (if needed) -->
<div class="register-result error" th:unless="${modificationComplete}">
<div class="result-icon-wrapper">
<div class="result-icon error-icon">
<span></span>
</div>
</div>
<div class="result-header">
<h1>잘못된 접근입니다</h1>
<p class="result-description">
수정 프로세스를 완료하지 않았거나 잘못된 경로로 접근하셨습니다.
</p>
</div>
<div class="result-actions">
<a th:href="@{/myapikey}" class="btn-primary">
목록으로 돌아가기
</a>
</div>
</div>
</section>
</th:block>
<th:block layout:fragment="contentScript">
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function() {
// Success animation
setTimeout(function() {
const successIcon = document.querySelector('.success-icon');
if (successIcon) {
successIcon.classList.add('animate');
}
}, 100);
});
</script>
<style>
/* Success checkmark animation */
.checkmark {
width: 52px;
height: 52px;
border-radius: 50%;
display: block;
stroke-width: 2;
stroke: #6BCF7F;
stroke-miterlimit: 10;
animation: rotate 1s ease-in-out;
}
.checkmark-circle {
stroke-dasharray: 166;
stroke-dashoffset: 166;
stroke-width: 2;
stroke-miterlimit: 10;
stroke: #6BCF7F;
animation: stroke 0.6s cubic-bezier(0.65, 0, 0.45, 1) forwards;
}
.checkmark-check {
transform-origin: 50% 50%;
stroke-dasharray: 48;
stroke-dashoffset: 48;
animation: stroke 0.3s cubic-bezier(0.65, 0, 0.45, 1) 0.6s forwards;
}
@keyframes stroke {
100% {
stroke-dashoffset: 0;
}
}
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
</th:block>
</body>
</html>
@@ -1,165 +0,0 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
layout:decorate="~{layout/kbank_apikey_layout}">
<head>
<title>API 키 상세</title>
</head>
<body>
<section layout:fragment="contentFragment" class="content">
<div class="content_wrap">
<div class="sub_title3">
<h2 class="title">인증키 정보</h2>
</div>
<div class="inner i_cs h_inner11">
<div class="form_type">
<div class="info1 info_mypage">
<p class="title">
<span>인증키 이름</span>
</p>
<div class="info_line">
<div class="info_box1 w_inp6">
<input type="hidden" id="prodClientId" th:value="${@encryptionUtil.encrypt(apiKey.clientid)}" class="common_input_type_1" disabled="disabled">
<input type="text" id="prodClientName" th:value="${apiKey.clientname}" class="common_input_type_1" disabled="disabled">
</div>
</div>
</div>
<div class="info1 info_mypage">
<p class="title">
<span>변경일</span>
</p>
<div class="info_line">
<div class="info_box1 w_inp6">
<input type="text" th:value="${#temporals.format(apiKey.modifiedon, 'yyyy.MM.dd')}" name="text" class="common_input_type_1" placeholder="2024.06.01" disabled="disabled">
</div>
</div>
</div>
<div class="info1 info_mypage">
<p class="title">
<span>인증키 상태</span>
</p>
<div class="info_line">
<div class="info_box1 w_inp6">
<input type="text" th:value="${apiKey.appstatus == '1' ? '활성화' : '비활성화'}" name="text" class="common_input_type_1" placeholder="활성화" disabled="disabled">
</div>
</div>
</div>
</div>
<div class="terms_box title">
<p>API 정보</p>
</div>
<div class="table_box">
<div class="pc-only">
<table class="table tb_key m_top" cellspacing="0" cellpadding="0" width="100%">
<colgroup>
<col width="60px">
<col width="166px">
<col width="230px">
<col width="120px">
</colgroup>
<thead>
<tr>
<th>No</th>
<th>서비스</th>
<th>API명</th>
<th>비고</th>
</tr>
</thead>
<tbody>
<tr th:each="api, apiStat : ${apiKey.apiList}">
<td th:text="${apiStat.count}">14</td>
<td th:text="${api.service}">공통</td>
<td th:text="${api.apiDesc}">회원여부조회</td>
<td>
</td>
</tr>
</tbody>
</table>
</div>
<div class="m-only">
<table class="table tb_key m_top" cellspacing="0" cellpadding="0" width="100%">
<colgroup>
<col width="46px">
<col width="90px">
<col width="140px">
<col width="60px">
</colgroup>
<thead>
<tr>
<th>No</th>
<th>서비스</th>
<th>API명</th>
<th>비고</th>
</tr>
</thead>
<tbody>
<tr th:each="api, apiStat : ${apiKey.apiList}">
<td th:text="${apiStat.count}">14</td>
<td th:text="${api.apiGroupId}">공통</td>
<td th:text="${api.apiDesc}">회원여부조회</td>
<td>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="btn_wrap bt_location">
<button type="button" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" class="btn_add btn_change_prod_api">API 변경</button>
</div>
<div class="btn_inventory btn_mtop">
<a th:href="@{/myapikey/api_key_prod_page}" class="common_btn_type_1">목록</a>
</div>
<!-- // 기본 정보 -->
</div>
</div>
</section>
<section sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" layout:fragment="pagePopups" class="content pop">
<div th:insert="~{fragment/apikey-request :: apiKeyRequest}" class="popup_total" id="apiKeyRequestPopup" style="display:none; z-index: 1000;"></div>
<th:block th:replace="~{fragment/apikey-request :: apiKeyRequestScript}"></th:block>
<div th:insert="~{fragment/apikey-request-prod :: prodApiKeyRequest}" class="popup_total" id="apiKeyProdRequestPopup" style="display:none; z-index: 1000;"></div>
<th:block th:replace="~{fragment/apikey-request-prod :: prodApiKeyRequestScript}"></th:block>
</section>
<th:block layout:fragment="contentScript">
<script>
$(document).ready(function() {
activateSelect();
$('.btn_keydel').on('click', function(event) {
event.preventDefault();
$('.loading-overlay').show();
const requestData = {
clientId: document.getElementById("prodClientId").value,
type: 'DELETE'
};
$.ajax({
url: '/myapikey/api_key_request',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(requestData)
}).done(function(response) {
customPopups.showConfirm(response.msg, function() {
window.location = '[[@{/myapikey/api_key_request/history}]]';
});
}).fail(function(jqXHR, textStatus, errorThrown) {
customPopups.showAlert('API 삭제 요청 중 오류가 발생했습니다: ' + errorThrown);
}).always(function() {
$('.loading-overlay').hide();
});
});
});
</script>
</th:block>
</body>
</html>
@@ -1,89 +0,0 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kbank_apikey_layout}">
<body>
<section layout:fragment="contentFragment" class="content">
<div class="content_wrap">
<div class="sub_title3">
<h2 class="title">인증키 목록</h2>
</div>
<div class="inner i_cs h_inner8">
<div class="tab-wrap">
<div class="tabs">
<ul class="tab_nav tab_bt">
<li>
<a th:href="@{/myapikey}">개발</a>
</li>
<li class="active">
<a th:href="@{/myapikey/api_key_prod_page}">운영</a>
</li>
</ul>
<div class="tab active">
<div class="tb_board_list">
<div class="table_box">
<table class="table table_min" cellspacing="0" cellpadding="0" width="100%">
<colgroup>
<col width="60px">
<col width="230px">
<col width="120px">
<col width="100px">
<col width="120px">
</colgroup>
<thead>
<tr>
<th>No</th>
<th>이름</th>
<th>인증키상태</th>
<th>사용 API 수</th>
<th>마지막수정일</th>
</tr>
</thead>
<tbody>
<tr th:each="apikey, status : ${apiKeys}">
<td th:text="${status.index + 1}">1</td>
<td>
<a th:href="@{/myapikey/api_key_prod_detail(id=${@encryptionUtil.encrypt(apikey.clientid)})}" th:text="${apikey.clientname}">수신</a>
</td>
<td th:text="${apikey.appstatus == '1' ? '활성화' : '비활성화'}">활성화</td>
<td th:text="${apikey.apiList.size()}">1</td>
<td th:text="${#temporals.format(apikey.modifiedon, 'yyyy.MM.dd')}"></td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="btn_wrap bt_location">
<button sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" type="button" class="btn_key btn_request_prod_api" id="requestApiKey">운영 인증키 신청</button>
</div>
<div class="pagination page_top" th:replace="~{fragment/pagination :: pagination(jsFunction='fn_select_page')}"></div>
<form name="listForm" th:action="@{/myapikey/api_key_prod_page}" method="get">
<input type="hidden" id="page" name="page"/>
</form>
</div>
</div>
</div>
</div>
</div>
</section>
<section sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" layout:fragment="pagePopups" class="content pop">
<div th:insert="~{fragment/apikey-request-prod :: prodApiKeyRequest}" id="apiKeyProdRequestPopup" class="popup_total" style="display: none"></div>
<th:block th:replace="~{fragment/apikey-request-prod :: prodApiKeyRequestScript}"></th:block>
</section>
<th:block layout:fragment="contentScript">
<script>
document.addEventListener('DOMContentLoaded', function() {
activateSelect();
});
function fn_select_page(pageNo) {
document.listForm.page.value = pageNo;
document.listForm.action = '[[@{/myapikey/api_key_prod_page}]]';
document.listForm.submit();
}
</script>
</th:block>
</body>
</html>
@@ -0,0 +1,511 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kjbank_base_layout}">
<body>
<th:block layout:fragment="contentFragment">
<section class="apikey-register-container">
<!-- Progress Indicator -->
<div class="register-progress">
<div class="progress-steps">
<div class="progress-step active">
<div class="step-number">1</div>
<div class="step-label">기본 정보</div>
</div>
<div class="progress-line"></div>
<div class="progress-step">
<div class="step-number">2</div>
<div class="step-label">API 선택</div>
</div>
<div class="progress-line"></div>
<div class="progress-step">
<div class="step-number">3</div>
<div class="step-label">완료</div>
</div>
</div>
</div>
<!-- Header -->
<div class="register-header">
<h1>API Key 생성</h1>
<h2>Step 1: 기본 정보 입력</h2>
<p class="header-description">
앱의 기본 정보를 입력해주세요. 이 정보는 API 관리 및 모니터링에 사용됩니다.
</p>
<!-- Error Message -->
<div th:if="${error}" class="alert alert-error" style="margin-top: 20px;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
<span th:text="${error}"></span>
</div>
</div>
<!-- Form Content -->
<div class="register-content">
<form id="registerStep1Form" method="post" th:action="@{/myapikey/register/step1}" th:object="${apiKeyRegistration}" class="register-form" enctype="multipart/form-data">
<div class="form-card">
<!-- App Icon -->
<div class="form-group">
<label for="appIcon" class="form-label required">
앱 아이콘
<span class="required-mark">*</span>
</label>
<div class="icon-upload-wrapper">
<div class="icon-preview" id="iconPreview">
<div class="icon-placeholder">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
<span>이미지 선택</span>
</div>
<img id="iconPreviewImage" style="display: none;" alt="Icon Preview">
<button type="button" class="btn-remove-icon" id="btnRemoveIcon" style="display: none;" onclick="removeAppIcon()" title="아이콘 삭제">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<input type="file"
id="appIcon"
name="appIcon"
class="icon-input"
accept="image/png,image/jpeg,image/jpg"
required>
<button type="button" class="btn-icon-select" onclick="document.getElementById('appIcon').click()">
파일 선택
</button>
</div>
<small class="form-hint">PNG, JPG 형식 (최대 2MB, 권장 크기: 512x512px)</small>
</div>
<!-- App Name -->
<div class="form-group">
<label for="appName" class="form-label required">
앱 이름
<span class="required-mark">*</span>
</label>
<input type="text"
id="appName"
name="appName"
th:field="*{appName}"
class="form-input"
placeholder="예: My Service API Client"
required
maxlength="100">
<small class="form-hint">API Key를 식별할 수 있는 고유한 이름을 입력하세요.</small>
</div>
<!-- App Description -->
<div class="form-group">
<label for="appDescription" class="form-label required">
앱 설명
<span class="required-mark">*</span>
</label>
<textarea id="appDescription"
name="appDescription"
th:field="*{appDescription}"
class="form-textarea"
placeholder="이 앱이 어떤 용도로 사용되는지 간단히 설명해주세요."
rows="4"
required
maxlength="500"></textarea>
<small class="form-hint">
<span class="char-counter">0 / 500</span>
</small>
</div>
<!-- Callback URL -->
<div class="form-group">
<label for="callbackUrl" class="form-label">
Callback URL
</label>
<input type="text"
id="callbackUrl"
name="callbackUrl"
th:field="*{callbackUrl}"
class="form-input"
placeholder="https://example.com/api/callback">
<small class="form-hint">OAuth 인증 후 리다이렉트될 URL을 입력하세요. (선택사항)</small>
</div>
<!-- IP Whitelist -->
<div class="form-group">
<label for="ipWhitelist" class="form-label">
IP 화이트리스트
</label>
<div class="ip-input-group">
<input type="text"
id="ipWhitelistInput"
class="form-input"
placeholder="예: 192.168.1.1 또는 10.0.0.0/24">
<button type="button" class="btn-add-ip" onclick="addIpAddress()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
추가
</button>
</div>
<small class="form-hint">허용할 IP 주소 또는 CIDR 대역을 입력하세요. (선택사항)</small>
<!-- IP List Display -->
<div class="ip-list" id="ipList" style="display: none;">
<div class="ip-list-header">
<span class="ip-count">등록된 IP: <strong id="ipCount">0</strong></span>
</div>
<div class="ip-items" id="ipItems">
<!-- Dynamically added IP items -->
</div>
</div>
<!-- Hidden input to store IP list -->
<input type="hidden" id="ipWhitelist" name="ipWhitelist" value="">
</div>
</div>
<!-- Form Actions -->
<div class="form-actions">
<a href="/myapikey" class="btn-secondary" id="btnCancel">
<span class="icon"></span>
취소
</a>
<button type="submit" class="btn-primary">
다음 단계
<span class="icon"></span>
</button>
</div>
</form>
</div>
</section>
</th:block>
<th:block layout:fragment="contentScript">
<script th:inline="javascript">
// IP address list management
let ipAddresses = [];
// Remove app icon
function removeAppIcon() {
const appIconInput = document.getElementById('appIcon');
const iconPreviewImage = document.getElementById('iconPreviewImage');
const iconPlaceholder = document.querySelector('.icon-placeholder');
const btnRemoveIcon = document.getElementById('btnRemoveIcon');
// Clear file input
appIconInput.value = '';
// Hide preview image and show placeholder
iconPreviewImage.style.display = 'none';
iconPreviewImage.src = '';
iconPlaceholder.style.display = 'flex';
// Hide delete button
btnRemoveIcon.style.display = 'none';
// Add required attribute back since no icon is selected
appIconInput.setAttribute('required', 'required');
}
// Add IP address to the list
function addIpAddress() {
const ipInput = document.getElementById('ipWhitelistInput');
const ipValue = ipInput.value.trim();
// Validate IP format
if (!ipValue) {
alert('IP 주소를 입력해주세요.');
return;
}
// Basic IP validation (supports IP and CIDR notation)
const ipPattern = /^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$/;
if (!ipPattern.test(ipValue)) {
alert('올바른 IP 주소 형식이 아닙니다.\n예: 192.168.1.1 또는 10.0.0.0/24');
return;
}
// Check for duplicates
if (ipAddresses.includes(ipValue)) {
alert('이미 추가된 IP 주소입니다.');
return;
}
// Add to array
ipAddresses.push(ipValue);
// Update UI
updateIpList();
// Clear input
ipInput.value = '';
ipInput.focus();
}
// Remove IP address from the list
function removeIpAddress(ip) {
const index = ipAddresses.indexOf(ip);
if (index > -1) {
ipAddresses.splice(index, 1);
updateIpList();
}
}
// Update IP list display
function updateIpList() {
const ipList = document.getElementById('ipList');
const ipItems = document.getElementById('ipItems');
const ipCount = document.getElementById('ipCount');
const ipWhitelistHidden = document.getElementById('ipWhitelist');
// Update count
ipCount.textContent = ipAddresses.length;
// Show/hide list container
if (ipAddresses.length > 0) {
ipList.style.display = 'block';
// Build IP items HTML
let itemsHTML = '';
ipAddresses.forEach(function(ip) {
itemsHTML += `
<div class="ip-item">
<span class="ip-address">${ip}</span>
<button type="button" class="btn-remove-ip" onclick="removeIpAddress('${ip}')" title="제거">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
`;
});
ipItems.innerHTML = itemsHTML;
} else {
ipList.style.display = 'none';
ipItems.innerHTML = '';
}
// Update hidden input with comma-separated values
ipWhitelistHidden.value = ipAddresses.join(',');
}
// Allow Enter key to add IP
document.addEventListener('DOMContentLoaded', function() {
// Only clear sessionStorage if starting a new registration (clear=true parameter)
// This preserves data during page refresh or back navigation from Step 2
const urlParams = new URLSearchParams(window.location.search);
const shouldClear = urlParams.get('clear') === 'true';
if (shouldClear) {
const keysToRemove = [];
for (let i = 0; i < sessionStorage.length; i++) {
const key = sessionStorage.key(i);
if (key && key.startsWith('registration_')) {
keysToRemove.push(key);
}
}
keysToRemove.forEach(key => sessionStorage.removeItem(key));
}
const form = document.getElementById('registerStep1Form');
const appDescTextarea = document.getElementById('appDescription');
const charCounter = document.querySelector('.char-counter');
const appIconInput = document.getElementById('appIcon');
const iconPreviewImage = document.getElementById('iconPreviewImage');
const iconPlaceholder = document.querySelector('.icon-placeholder');
const ipWhitelistInput = document.getElementById('ipWhitelistInput');
// Restore IP whitelist from session (Thymeleaf provides this)
/*<![CDATA[*/
const sessionIpWhitelist = /*[[${apiKeyRegistration.ipWhitelistAsString}]]*/ '';
if (sessionIpWhitelist && sessionIpWhitelist.trim() !== '') {
const ips = sessionIpWhitelist.split(',');
ips.forEach(function(ip) {
const trimmedIp = ip.trim();
if (trimmedIp && !ipAddresses.includes(trimmedIp)) {
ipAddresses.push(trimmedIp);
}
});
updateIpList();
}
// Restore app icon preview from session if exists
const appIconFileName = /*[[${apiKeyRegistration.appIconFileName}]]*/ '';
const appIconBase64 = /*[[${apiKeyRegistration.appIconBase64}]]*/ '';
if (appIconFileName && appIconFileName.trim() !== '' && appIconBase64) {
// Display the actual uploaded image from session
iconPreviewImage.src = appIconBase64;
iconPreviewImage.style.display = 'block';
iconPlaceholder.style.display = 'none';
// Show delete button
const btnRemoveIcon = document.getElementById('btnRemoveIcon');
btnRemoveIcon.style.display = 'flex';
// Remove required attribute since we already have an image in session
appIconInput.removeAttribute('required');
}
/*]]>*/
// Initialize character counter for existing description
if (appDescTextarea && charCounter) {
const currentLength = appDescTextarea.value.length;
const maxLength = appDescTextarea.getAttribute('maxlength');
charCounter.textContent = currentLength + ' / ' + maxLength;
}
// Character counter for description
if (appDescTextarea && charCounter) {
appDescTextarea.addEventListener('input', function() {
const currentLength = this.value.length;
const maxLength = this.getAttribute('maxlength');
charCounter.textContent = currentLength + ' / ' + maxLength;
// Change color when near limit
if (currentLength > maxLength * 0.9) {
charCounter.style.color = '#FF6B6B';
} else {
charCounter.style.color = '#94A3B8';
}
});
}
// App icon preview
if (appIconInput) {
appIconInput.addEventListener('change', function(e) {
const file = e.target.files[0];
const btnRemoveIcon = document.getElementById('btnRemoveIcon');
if (file) {
// Validate file size (max 2MB)
if (file.size > 2 * 1024 * 1024) {
alert('파일 크기는 2MB를 초과할 수 없습니다.');
this.value = '';
return;
}
// Validate file type - Only PNG and JPG allowed
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg'];
if (!allowedTypes.includes(file.type)) {
alert('PNG, JPG 형식만 업로드 가능합니다.');
this.value = '';
return;
}
// Show preview
const reader = new FileReader();
reader.onload = function(event) {
iconPreviewImage.src = event.target.result;
iconPreviewImage.style.display = 'block';
iconPlaceholder.style.display = 'none';
// Show delete button
btnRemoveIcon.style.display = 'flex';
};
reader.readAsDataURL(file);
// Remove required attribute since a file is now selected
appIconInput.removeAttribute('required');
}
});
}
// IP whitelist input - Enter key handler
if (ipWhitelistInput) {
ipWhitelistInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
addIpAddress();
}
});
}
// Cancel button handler - clear sessionStorage and notify server
const btnCancel = document.getElementById('btnCancel');
if (btnCancel) {
btnCancel.addEventListener('click', function(e) {
e.preventDefault();
// Clear all registration-related data from sessionStorage
const keysToRemove = [];
for (let i = 0; i < sessionStorage.length; i++) {
const key = sessionStorage.key(i);
if (key && key.startsWith('registration_')) {
keysToRemove.push(key);
}
}
keysToRemove.forEach(key => sessionStorage.removeItem(key));
// Navigate to cancel endpoint to clear server session
window.location.href = '/myapikey/register/cancel';
});
}
// Form validation
form.addEventListener('submit', function(e) {
const appIcon = document.getElementById('appIcon').files[0];
const appName = document.getElementById('appName').value.trim();
const appDescription = document.getElementById('appDescription').value.trim();
const callbackUrl = document.getElementById('callbackUrl').value.trim();
// Check if app icon exists (either newly uploaded or from session)
const hasSessionIcon = appIconFileName && appIconFileName.trim() !== '';
if (!appIcon && !hasSessionIcon) {
e.preventDefault();
alert('앱 아이콘을 선택해주세요.');
return false;
}
if (!appName) {
e.preventDefault();
alert('앱 이름을 입력해주세요.');
document.getElementById('appName').focus();
return false;
}
if (!appDescription) {
e.preventDefault();
alert('앱 설명을 입력해주세요.');
document.getElementById('appDescription').focus();
return false;
}
// Validate URL format only if callbackUrl is provided (optional field)
if (callbackUrl && callbackUrl.trim() !== '') {
try {
new URL(callbackUrl);
} catch (error) {
e.preventDefault();
alert('올바른 URL 형식이 아닙니다.\n예: https://example.com/api/callback');
document.getElementById('callbackUrl').focus();
return false;
}
}
// Store data in sessionStorage for later steps
sessionStorage.setItem('registration_appName', appName);
sessionStorage.setItem('registration_appDescription', appDescription);
sessionStorage.setItem('registration_callbackUrl', callbackUrl);
sessionStorage.setItem('registration_ipWhitelist', ipAddresses.join(','));
});
});
</script>
</th:block>
</body>
</html>
@@ -0,0 +1,670 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kjbank_base_layout}">
<body>
<th:block layout:fragment="contentFragment">
<!-- Progress Indicator -->
<div class="register-progress">
<div class="progress-steps">
<div class="progress-step completed">
<div class="step-number"></div>
<div class="step-label">기본 정보</div>
</div>
<div class="progress-line filled"></div>
<div class="progress-step active">
<div class="step-number">2</div>
<div class="step-label">API 선택</div>
</div>
<div class="progress-line"></div>
<div class="progress-step">
<div class="step-number">3</div>
<div class="step-label">완료</div>
</div>
</div>
</div>
<!-- Header -->
<div class="register-header">
<h1>API Key 생성</h1>
<h2>Step 2: API 선택</h2>
<p class="header-description">
사용할 API를 선택해주세요. 나중에 추가하거나 변경할 수 있습니다.
</p>
</div>
<!-- Main Container with Sidebar Layout -->
<section class="apikey-register-container with-sidebar">
<!-- Sidebar Navigation -->
<aside class="apikey-register-sidebar" id="apiSidebar">
<nav class="apikey-sidebar-nav">
<!-- All APIs -->
<div class="menu-section">
<div class="menu-title active" data-group="">
전체
<span class="api-count" th:text="${totalApiCount ?: 0}">0</span>
</div>
</div>
<!-- Service Categories -->
<div class="menu-section" th:each="service : ${apiServices}">
<div class="menu-title"
th:data-group="${service.id}">
[[${service.groupName}]]
<span class="api-count" th:text="${service.apiGroupApiList.size()}">0</span>
</div>
</div>
</nav>
</aside>
<!-- Mobile Menu Toggle -->
<button class="apikey-mobile-toggle" id="mobileToggle" aria-label="메뉴 열기">
</button>
<!-- Mobile Overlay -->
<div class="apikey-mobile-overlay" id="mobileOverlay"></div>
<!-- Main Content -->
<main class="apikey-register-content">
<form id="registerStep2Form" method="post" th:action="@{/myapikey/register/step2}" class="register-form">
<!-- Search Box with Select All -->
<div class="api-filter-header">
<div class="select-all-wrapper" id="selectAllWrapper" style="display: none;">
<label class="select-all-label">
<input type="checkbox" id="selectAllCheckbox" class="select-all-checkbox">
<span id="selectAllText">전체 선택</span>
</label>
</div>
<div class="api-search-box">
<input type="text"
id="apiSearch"
class="search-input"
placeholder="API 검색...">
<span class="search-icon">🔍</span>
</div>
</div>
<!-- Result Count -->
<div class="api-result-count">
<strong id="visibleCount">0</strong>
</div>
<!-- API Cards Grid -->
<div class="api-selection-card-grid" id="apiCardGrid">
<!-- Loading state -->
<div class="loading-state" id="loadingState" style="grid-column: 1 / -1;">
<div class="loading-spinner"></div>
<p>API 목록을 불러오는 중...</p>
</div>
<!-- Empty state (initially hidden) -->
<div class="empty-api-state" id="emptyState" style="display: none; grid-column: 1 / -1;">
<div class="empty-icon">📦</div>
<h3>API를 선택해주세요</h3>
<p>왼쪽 메뉴에서 서비스를 선택하면 해당 API 목록이 표시됩니다.</p>
</div>
<!-- API cards will be dynamically loaded here -->
</div>
<!-- Form Actions -->
<div class="form-actions">
<button type="button" id="btnPrevStep" class="btn-secondary">
<span class="icon"></span>
이전 단계
</button>
<button type="submit" class="btn-primary">
저장
<span class="icon"></span>
</button>
</div>
</form>
</main>
</section>
<!-- Floating Cart Button -->
<button type="button" class="floating-cart-btn" id="floatingCartBtn" style="display: none;">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="9" cy="21" r="1"></circle>
<circle cx="20" cy="21" r="1"></circle>
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"></path>
</svg>
<span class="cart-text">선택된 API:</span>
<span class="cart-badge" id="cartBadge">0</span>
</button>
<!-- Selected APIs Modal - Uses base modal component -->
<div class="selected-apis-modal" id="selectedApisModal" style="display: none;">
<div class="modal-backdrop" id="modalOverlay"></div>
<div class="modal-dialog">
<div class="modal-header">
<h3 class="modal-title">선택된 API 목록</h3>
<button type="button" class="modal-close" id="modalCloseBtn">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="modal-body">
<div class="selected-apis-list" id="modalSelectedList">
<!-- Dynamically populated -->
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" id="modalCancelBtn">닫기</button>
</div>
</div>
</div>
</th:block>
<th:block layout:fragment="contentScript">
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function() {
// DOM Elements
const form = document.getElementById('registerStep2Form');
const searchInput = document.getElementById('apiSearch');
const sidebar = document.getElementById('apiSidebar');
const mobileToggle = document.getElementById('mobileToggle');
const mobileOverlay = document.getElementById('mobileOverlay');
const menuTitles = document.querySelectorAll('.menu-title');
const visibleCount = document.getElementById('visibleCount');
const apiCardGrid = document.getElementById('apiCardGrid');
const loadingState = document.getElementById('loadingState');
const emptyState = document.getElementById('emptyState');
let currentFilter = ''; // Empty string means "all"
let currentServiceName = '전체'; // Store current service name
let allApis = []; // Store loaded APIs
let selectedApis = new Set(); // Track selected API IDs
// Restore selected APIs from session
const sessionSelectedApis = /*[[${apiKeyRegistration.selectedApis}]]*/ [];
if (sessionSelectedApis && Array.isArray(sessionSelectedApis)) {
sessionSelectedApis.forEach(function(apiId) {
selectedApis.add(apiId);
});
}
// Load APIs via AJAX
function loadApis(groupId) {
// Show loading state
loadingState.style.display = 'block';
emptyState.style.display = 'none';
// Remove existing API cards
document.querySelectorAll('.api-selection-card').forEach(card => card.remove());
// Build URL with optional groupId filter
let url = '/apis/for_request';
if (groupId) {
url += '?groupIds=' + encodeURIComponent(groupId);
}
fetch(url)
.then(response => response.json())
.then(apis => {
allApis = apis;
loadingState.style.display = 'none';
if (apis.length === 0) {
emptyState.style.display = 'block';
visibleCount.textContent = '0';
return;
}
renderApiCards(apis);
visibleCount.textContent = apis.length;
updateSelectAllUI();
})
.catch(error => {
console.error('Failed to load APIs:', error);
loadingState.style.display = 'none';
emptyState.querySelector('h3').textContent = 'API 로드 실패';
emptyState.querySelector('p').textContent = '다시 시도해주세요.';
emptyState.style.display = 'block';
});
}
// Render API cards
function renderApiCards(apis) {
const fragment = document.createDocumentFragment();
apis.forEach(api => {
const card = createApiCard(api);
fragment.appendChild(card);
});
apiCardGrid.appendChild(fragment);
// Re-attach event listeners
attachCardEventListeners();
}
// Create API card element
function createApiCard(api) {
const card = document.createElement('div');
card.className = 'api-selection-card';
card.setAttribute('data-group', api.apiGroupId || '');
card.setAttribute('data-name', (api.apiName || '').toLowerCase());
card.setAttribute('data-desc', (api.apiSimpleDescription || '').toLowerCase());
card.setAttribute('data-api-id', api.apiId);
const isSelected = selectedApis.has(api.apiId);
if (isSelected) {
card.classList.add('selected');
}
card.innerHTML = `
<div class="checkbox-wrapper">
<input type="checkbox"
name="selectedApis"
value="${api.apiId}"
id="api-${api.apiId}"
class="api-checkbox"
${isSelected ? 'checked' : ''}>
</div>
<label class="api-card-content" for="api-${api.apiId}">
<div class="api-card-header">
<span class="api-card-badge">${api.service || '카테고리'}</span>
<span class="api-method">${api.apiMethod || 'GET'}</span>
</div>
<h3 class="api-name">${api.apiName || 'API 이름'}</h3>
<p class="api-description">${api.apiSimpleDescription || 'API 설명이 없습니다.'}</p>
<div class="api-endpoint">
<code>${api.apiUrl || '/api/v1/example'}</code>
</div>
</label>
`;
return card;
}
// Attach event listeners to cards
function attachCardEventListeners() {
const checkboxes = document.querySelectorAll('.api-checkbox');
const apiCards = document.querySelectorAll('.api-selection-card');
checkboxes.forEach(checkbox => {
checkbox.addEventListener('change', function() {
updateCardSelection(this);
updateSelectedCount();
});
});
apiCards.forEach(card => {
card.addEventListener('click', function(e) {
if (e.target.classList.contains('api-checkbox') || e.target.tagName === 'LABEL') {
return;
}
const checkbox = card.querySelector('.api-checkbox');
if (checkbox) {
checkbox.checked = !checkbox.checked;
updateCardSelection(checkbox);
updateSelectedCount();
}
});
});
}
// Update card visual state
function updateCardSelection(checkbox) {
const card = checkbox.closest('.api-selection-card');
if (checkbox.checked) {
card.classList.add('selected');
selectedApis.add(checkbox.value);
} else {
card.classList.remove('selected');
selectedApis.delete(checkbox.value);
}
}
// Update selected count
function updateSelectedCount() {
// Update floating cart button
const floatingCartBtn = document.getElementById('floatingCartBtn');
const cartBadge = document.getElementById('cartBadge');
if (selectedApis.size > 0) {
floatingCartBtn.style.display = 'flex';
cartBadge.textContent = selectedApis.size;
} else {
floatingCartBtn.style.display = 'none';
}
// Update modal list
updateModalList();
// Update select all checkbox state
updateSelectAllCheckboxState();
}
// Update select all UI visibility and text
function updateSelectAllUI() {
const selectAllWrapper = document.getElementById('selectAllWrapper');
const selectAllText = document.getElementById('selectAllText');
if (currentFilter === '') {
// Hide select all when "전체" is selected
selectAllWrapper.style.display = 'none';
} else {
// Show select all with service name
selectAllWrapper.style.display = 'flex';
selectAllText.textContent = currentServiceName + ' API 전체 선택';
}
updateSelectAllCheckboxState();
}
// Update select all checkbox state based on visible cards
function updateSelectAllCheckboxState() {
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
const visibleCards = Array.from(document.querySelectorAll('.api-selection-card')).filter(card => card.style.display !== 'none');
if (visibleCards.length === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
return;
}
const visibleCheckboxes = visibleCards.map(card => card.querySelector('.api-checkbox'));
const checkedCount = visibleCheckboxes.filter(cb => cb.checked).length;
if (checkedCount === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
} else if (checkedCount === visibleCheckboxes.length) {
selectAllCheckbox.checked = true;
selectAllCheckbox.indeterminate = false;
} else {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = true;
}
}
// Update modal selected APIs list
function updateModalList() {
const modalSelectedList = document.getElementById('modalSelectedList');
modalSelectedList.innerHTML = '';
if (selectedApis.size === 0) {
modalSelectedList.innerHTML = '<p class="empty-message">선택된 API가 없습니다.</p>';
return;
}
// Get all checked checkboxes
const checkedBoxes = document.querySelectorAll('.api-checkbox:checked');
checkedBoxes.forEach(function(checkbox) {
const card = checkbox.closest('.api-selection-card');
const apiName = card.querySelector('.api-name').textContent;
const apiPill = document.createElement('div');
apiPill.className = 'api-pill';
apiPill.innerHTML = `
<span class="api-pill-name">${apiName}</span>
<button type="button" class="api-pill-remove" data-value="${checkbox.value}" aria-label="Remove ${apiName}">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
`;
modalSelectedList.appendChild(apiPill);
});
// Add remove handlers
document.querySelectorAll('.api-pill-remove').forEach(function(btn) {
btn.addEventListener('click', function() {
const value = this.getAttribute('data-value');
const checkbox = document.querySelector('.api-checkbox[value="' + value + '"]');
if (checkbox) {
checkbox.checked = false;
updateCardSelection(checkbox);
updateSelectedCount();
}
});
});
}
// Search functionality
if (searchInput) {
searchInput.addEventListener('input', function() {
const searchTerm = this.value.toLowerCase();
let visibleCountNum = 0;
const apiCards = document.querySelectorAll('.api-selection-card');
apiCards.forEach(function(card) {
const apiName = card.getAttribute('data-name');
const apiDesc = card.getAttribute('data-desc');
// Check if matches search
const matchesSearch = apiName.includes(searchTerm) || apiDesc.includes(searchTerm);
if (matchesSearch) {
card.style.display = '';
visibleCountNum++;
} else {
card.style.display = 'none';
}
});
visibleCount.textContent = visibleCountNum;
// Update select all checkbox state after search
updateSelectAllCheckboxState();
});
}
// Sidebar category selection
menuTitles.forEach(function(title) {
title.addEventListener('click', function(e) {
e.preventDefault();
// Remove active from all
menuTitles.forEach(t => t.classList.remove('active'));
// Add active to clicked
this.classList.add('active');
const groupId = this.getAttribute('data-group');
currentFilter = groupId;
// Store service name for select all label
currentServiceName = this.textContent.trim().split('\n')[0].trim();
// Load APIs for selected service
loadApis(groupId);
// Clear search when changing category
if (searchInput) {
searchInput.value = '';
}
// Close mobile menu if open
if (window.innerWidth <= 768 && sidebar.classList.contains('mobile-open')) {
closeMobileMenu();
}
});
});
// Mobile menu toggle
function closeMobileMenu() {
sidebar.classList.remove('mobile-open');
mobileOverlay.classList.remove('active');
mobileToggle.innerHTML = '☰';
mobileToggle.setAttribute('aria-label', '메뉴 열기');
}
if (mobileToggle && sidebar && mobileOverlay) {
mobileToggle.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
sidebar.classList.toggle('mobile-open');
mobileOverlay.classList.toggle('active');
if (sidebar.classList.contains('mobile-open')) {
this.innerHTML = '✕';
this.setAttribute('aria-label', '메뉴 닫기');
} else {
this.innerHTML = '☰';
this.setAttribute('aria-label', '메뉴 열기');
}
});
// Close menu on overlay click
mobileOverlay.addEventListener('click', function() {
closeMobileMenu();
});
}
// Close mobile menu on resize to desktop
window.addEventListener('resize', function() {
if (window.innerWidth > 768 && sidebar.classList.contains('mobile-open')) {
closeMobileMenu();
}
});
// Modal control
const floatingCartBtn = document.getElementById('floatingCartBtn');
const selectedApisModal = document.getElementById('selectedApisModal');
const modalOverlay = document.getElementById('modalOverlay');
const modalCloseBtn = document.getElementById('modalCloseBtn');
const modalCancelBtn = document.getElementById('modalCancelBtn');
// Open modal
function openModal() {
selectedApisModal.style.display = 'block';
// Add show class to backdrop and modal for visibility
setTimeout(function() {
if (modalOverlay) {
modalOverlay.classList.add('show');
}
selectedApisModal.classList.add('show');
}, 10);
document.body.style.overflow = 'hidden'; // Prevent background scroll
}
// Close modal
function closeModal() {
// Remove show class first for transition
if (modalOverlay) {
modalOverlay.classList.remove('show');
}
selectedApisModal.classList.remove('show');
// Hide modal after transition
setTimeout(function() {
selectedApisModal.style.display = 'none';
}, 300);
document.body.style.overflow = ''; // Restore scroll
}
// Floating cart button click
if (floatingCartBtn) {
floatingCartBtn.addEventListener('click', openModal);
}
// Modal overlay click
if (modalOverlay) {
modalOverlay.addEventListener('click', closeModal);
}
// Modal close button click
if (modalCloseBtn) {
modalCloseBtn.addEventListener('click', closeModal);
}
// Modal cancel button click
if (modalCancelBtn) {
modalCancelBtn.addEventListener('click', closeModal);
}
// Close modal on ESC key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && selectedApisModal.style.display === 'block') {
closeModal();
}
});
// Select All checkbox event
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
if (selectAllCheckbox) {
selectAllCheckbox.addEventListener('change', function() {
const isChecked = this.checked;
const visibleCards = Array.from(document.querySelectorAll('.api-selection-card')).filter(card => card.style.display !== 'none');
visibleCards.forEach(function(card) {
const checkbox = card.querySelector('.api-checkbox');
if (checkbox) {
checkbox.checked = isChecked;
updateCardSelection(checkbox);
}
});
updateSelectedCount();
});
}
// Previous step button - save current selections before going back
const btnPrevStep = document.getElementById('btnPrevStep');
if (btnPrevStep) {
btnPrevStep.addEventListener('click', function(e) {
e.preventDefault();
// Change form action to save endpoint
const originalAction = form.action;
form.action = '/myapikey/register/step2/save';
// Submit the form to save selections
form.submit();
});
}
// Form validation
form.addEventListener('submit', function(e) {
if (selectedApis.size === 0) {
e.preventDefault();
if (confirm('API를 선택하지 않으셨습니다. 나중에 추가하시겠습니까?')) {
// Allow form submission without selected APIs
form.submit();
}
return false;
}
// Store selected APIs in sessionStorage
sessionStorage.setItem('registration_selectedApis', JSON.stringify(Array.from(selectedApis)));
});
// Initialize
updateSelectedCount(); // This will show count from restored session data
// Load all APIs automatically on page load (empty string = all APIs)
loadApis('');
// Load data from step 1 (if needed for display)
const appName = sessionStorage.getItem('registration_appName');
if (appName) {
console.log('App Name from Step 1:', appName);
}
// Log restored selections for debugging
if (selectedApis.size > 0) {
console.log('Restored ' + selectedApis.size + ' selected APIs from session:', Array.from(selectedApis));
}
});
</script>
</th:block>
</body>
</html>
@@ -0,0 +1,251 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kjbank_base_layout}">
<body>
<th:block layout:fragment="contentFragment">
<section class="apikey-register-container">
<!-- Progress Indicator -->
<div class="register-progress">
<div class="progress-steps">
<div class="progress-step completed">
<div class="step-number"></div>
<div class="step-label">기본 정보</div>
</div>
<div class="progress-line filled"></div>
<div class="progress-step completed">
<div class="step-number"></div>
<div class="step-label">API 선택</div>
</div>
<div class="progress-line filled"></div>
<div class="progress-step active completed">
<div class="step-number"></div>
<div class="step-label">완료</div>
</div>
</div>
</div>
<!-- Success Result -->
<div class="register-result success" th:if="${registrationComplete}">
<!-- Success Icon Animation -->
<div class="result-icon-wrapper">
<div class="result-icon success-icon">
<svg class="checkmark" viewBox="0 0 52 52">
<circle class="checkmark-circle" cx="26" cy="26" r="25" fill="none"/>
<path class="checkmark-check" fill="none" d="M14.1 27.2l7.1 7.2 16.7-16.8"/>
</svg>
</div>
</div>
<!-- Success Message -->
<div class="result-header">
<h1>API Key 생성 완료!</h1>
<p class="result-description">
API Key가 성공적으로 생성되었습니다. 아래 정보를 안전한 곳에 보관해주세요.
</p>
</div>
<!-- API Key Display -->
<div class="result-content">
<!-- Action Buttons -->
<div class="result-actions">
<a href="/myapikey" class="btn-secondary">
<span class="icon">📋</span>
API Key 목록으로
</a>
</div>
</div>
</div>
<!-- Error Result (if needed) -->
<div class="register-result error" th:unless="${registrationComplete}" style="display: none;">
<div class="result-icon-wrapper">
<div class="result-icon error-icon">
<span></span>
</div>
</div>
<div class="result-header">
<h1>API Key 생성 실패</h1>
<p class="result-description">
API Key 생성 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.
</p>
</div>
<div class="result-actions">
<a href="/myapikey/register/step1" class="btn-secondary">
다시 시도
</a>
<a href="/myapikey" class="btn-primary">
목록으로
</a>
</div>
</div>
</section>
</th:block>
<th:block layout:fragment="contentScript">
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function() {
// Load data from sessionStorage
const appName = sessionStorage.getItem('registration_appName');
const selectedApis = JSON.parse(sessionStorage.getItem('registration_selectedApis') || '[]');
// Update display with actual data
if (appName) {
document.getElementById('resultAppName').textContent = appName;
}
// Copy to clipboard functionality
document.querySelectorAll('.btn-copy').forEach(function(btn) {
btn.addEventListener('click', function() {
const targetId = this.getAttribute('data-copy');
const targetElement = document.getElementById(targetId);
const textToCopy = targetElement.textContent;
// Create temporary input
const tempInput = document.createElement('input');
tempInput.value = textToCopy;
document.body.appendChild(tempInput);
tempInput.select();
try {
document.execCommand('copy');
// Visual feedback
const originalText = this.innerHTML;
this.innerHTML = '<span class="copy-icon"></span> 복사됨';
this.classList.add('copied');
setTimeout(() => {
this.innerHTML = originalText;
this.classList.remove('copied');
}, 2000);
} catch (err) {
console.error('Failed to copy:', err);
alert('복사에 실패했습니다. 수동으로 복사해주세요.');
}
document.body.removeChild(tempInput);
});
});
// Toggle visibility for secret key
document.querySelectorAll('.btn-toggle-visibility').forEach(function(btn) {
btn.addEventListener('click', function() {
const targetId = this.getAttribute('data-target');
const targetElement = document.getElementById(targetId);
if (targetElement.classList.contains('blurred')) {
targetElement.classList.remove('blurred');
this.innerHTML = '<span class="visibility-icon">🙈</span>';
} else {
targetElement.classList.add('blurred');
this.innerHTML = '<span class="visibility-icon">👁️</span>';
}
});
});
// Download key information
document.getElementById('downloadKeyBtn')?.addEventListener('click', function() {
const keyInfo = {
appName: document.getElementById('resultAppName').textContent,
clientId: document.getElementById('clientId').textContent,
clientSecret: document.getElementById('clientSecret').textContent,
createdAt: new Date().toISOString(),
selectedApis: selectedApis
};
const dataStr = JSON.stringify(keyInfo, null, 2);
const dataUri = 'data:application/json;charset=utf-8,' + encodeURIComponent(dataStr);
const exportFileDefaultName = 'apikey_' + keyInfo.appName.replace(/\s+/g, '_') + '.json';
const linkElement = document.createElement('a');
linkElement.setAttribute('href', dataUri);
linkElement.setAttribute('download', exportFileDefaultName);
linkElement.click();
});
// Clear session storage after successful registration
if (/*[[${registrationComplete}]]*/ false) {
sessionStorage.removeItem('registration_appName');
sessionStorage.removeItem('registration_appDescription');
sessionStorage.removeItem('registration_appType');
sessionStorage.removeItem('registration_environment');
sessionStorage.removeItem('registration_selectedApis');
}
// Success animation
setTimeout(function() {
const successIcon = document.querySelector('.success-icon');
if (successIcon) {
successIcon.classList.add('animate');
}
}, 100);
});
</script>
<style>
/* Blur effect for secret key */
.key-value.blurred {
filter: blur(5px);
user-select: none;
}
/* Copy button feedback */
.btn-copy.copied {
background-color: #6BCF7F !important;
color: white !important;
}
/* Success checkmark animation */
.checkmark {
width: 52px;
height: 52px;
border-radius: 50%;
display: block;
stroke-width: 2;
stroke: #6BCF7F;
stroke-miterlimit: 10;
animation: rotate 1s ease-in-out;
}
.checkmark-circle {
stroke-dasharray: 166;
stroke-dashoffset: 166;
stroke-width: 2;
stroke-miterlimit: 10;
stroke: #6BCF7F;
animation: stroke 0.6s cubic-bezier(0.65, 0, 0.45, 1) forwards;
}
.checkmark-check {
transform-origin: 50% 50%;
stroke-dasharray: 48;
stroke-dashoffset: 48;
animation: stroke 0.3s cubic-bezier(0.65, 0, 0.45, 1) 0.6s forwards;
}
@keyframes stroke {
100% {
stroke-dashoffset: 0;
}
}
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
</th:block>
</body>
</html>
@@ -1,191 +0,0 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kbank_base_layout}">
<head>
<title>API 요청 상세</title>
</head>
<body>
<section class="content" layout:fragment="contentFragment">
<div class="content_wrap">
<div class="sub_title3">
<h2 class="title">운영 인증키 요청 정보</h2>
</div>
<div class="inner i_cs h_inner11">
<div class="form_type">
<div class="info1 info_mypage">
<p class="title">
<span>이름</span>
</p>
<div class="info_line">
<div class="info_box1 w_inp6">
<input type="text" name="text" class="common_input_type_1" disabled="disabled" th:value="${appRequest.clientName}">
</div>
</div>
</div>
<div class="info1 info_mypage">
<p class="title">
<span>유형</span>
</p>
<div class="info_line">
<div class="info_box1 w_inp6">
<input type="text" name="text" class="common_input_type_1" disabled="disabled" th:value="${appRequest.type.description}">
</div>
</div>
</div>
<div class="info1 info_mypage">
<p class="title">
<span>상태</span>
</p>
<div class="info_line">
<div class="info_box1 w_inp6">
<input type="text" name="text" class="common_input_type_1" disabled="disabled" th:value="${appRequest.approval.approvalStatus.getDescription()}">
</div>
</div>
</div>
<div class="info1 info_mypage">
<p class="title">
<span>사유</span>
</p>
<div class="info_line">
<div class="info_box1 w_inp6">
<input type="text" name="text" class="common_input_type_1" disabled="disabled" th:value="${appRequest.reason}">
</div>
</div>
</div>
<div class="info1 info_mypage">
<p class="title">
<span>요청일시</span>
</p>
<div class="info_line">
<div class="info_box1 w_inp6">
<input type="text" name="text" class="common_input_type_1" disabled="disabled" th:value="${#temporals.format(appRequest.approval.createdDate, 'yyyy-MM-dd HH:mm')}">
</div>
</div>
</div>
</div>
<div class="terms_box title">
<p>API 정보</p>
</div>
<div class="table_box">
<div class="pc-only">
<table class="table tb_key m_top" cellspacing="0" cellpadding="0" width="100%">
<colgroup>
<col width="60px">
<col width="166px">
<col width="230px">
<col width="80px">
</colgroup>
<thead>
<tr>
<th>No</th>
<th>서비스</th>
<th>API명</th>
<th></th>
</tr>
</thead>
<tbody>
<tr th:each="api,status : ${appRequest.apiSpecList}"
th:class="${appRequest.type != null ?
((appRequest.type.toString() == 'MODIFY' or appRequest.type.toString() == 'PROD_MODIFY') ?
(#strings.isEmpty(appRequest.prevApiList) ? '' :
(!#strings.contains(appRequest.prevApiList, api.apiId) ? 'highlight_new' : '')) :
((appRequest.type.toString() == 'DELETE' or appRequest.type.toString() == 'PROD_DELETE') ? 'highlight_delete' : '')) : ''}">
<td th:text="${status.index + 1}"></td>
<td th:text="${api.service}"></td>
<td th:text="${api.apiName}"></td>
<td th:class="status"
th:text="${(appRequest.type.toString() =='MODIFY' or appRequest.type.toString() == 'PROD_MODIFY') ?
(#strings.isEmpty(appRequest.prevApiList) ? '' : (#strings.contains(appRequest.prevApiList, api.apiId) ? '' : '추가')) : ''}"></td>
</tr>
<tr class="highlight_delete"
th:if="${!#strings.isEmpty(appRequest.prevApiList) and (appRequest.type != null and
(appRequest.type.toString() == 'MODIFY' or appRequest.type.toString() == 'PROD_MODIFY'))}"
th:each="apiId : ${#strings.arraySplit(appRequest.prevApiList,',')}"
th:with="api=${@apiService.selectDetail(apiId)},
serviceDto=${@apiServiceService.findApiServiceByApiId(apiId)}"
th:unless="${#strings.contains(appRequest.apiList, apiId)}">
<td></td>
<td th:text="${serviceDto != null ? serviceDto.getGroupName() : ''}"></td>
<td th:text="${api.apiName}"></td>
<td th:class="status">삭제</td>
</tr>
</tbody>
</table>
</div>
<div class="m-only">
<table class="table tb_key m_top" cellspacing="0" cellpadding="0" width="100%">
<colgroup>
<col width="46px">
<col width="166px">
<col width="140px">
<col width="40px">
</colgroup>
<thead>
<tr>
<th>No</th>
<th>서비스</th>
<th>API명</th>
<th></th>
</tr>
</thead>
<tbody>
<tr th:each="api,status : ${appRequest.apiSpecList}"
th:class="${appRequest.type != null ?
((appRequest.type.toString() == 'MODIFY' or appRequest.type.toString() == 'PROD_MODIFY') ?
(#strings.isEmpty(appRequest.prevApiList) ? '' :
(!#strings.contains(appRequest.prevApiList, api.apiId) ? 'highlight_new' : '')) :
((appRequest.type.toString() == 'DELETE' or appRequest.type.toString() == 'PROD_DELETE') ? 'highlight_delete' : '')) : ''}">
<td th:text="${status.index + 1}"></td>
<td th:text="${api.service}"></td>
<td th:text="${api.apiName}"></td>
<td th:class="status"
th:text="${(appRequest.type.toString() =='MODIFY' or appRequest.type.toString() == 'PROD_MODIFY') ?
(#strings.isEmpty(appRequest.prevApiList) ? '' : (#strings.contains(appRequest.prevApiList, api.apiId) ? '' : '추가')) : ''}"></td>
</tr>
<tr class="highlight_delete"
th:if="${!#strings.isEmpty(appRequest.prevApiList) and (appRequest.type != null and
(appRequest.type.toString() == 'MODIFY' or appRequest.type.toString() == 'PROD_MODIFY'))}"
th:each="apiId : ${#strings.arraySplit(appRequest.prevApiList,',')}"
th:with="api=${@apiService.selectDetail(apiId)},
serviceDto=${@apiServiceService.findApiServiceByApiId(apiId)}"
th:unless="${#strings.contains(appRequest.apiList, apiId)}">
<td></td>
<td th:text="${serviceDto != null ? serviceDto.getGroupName() : ''}"></td>
<td th:text="${api.apiName}"></td>
<td th:class="status">삭제</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="btn_wrap bt_location" th:if="${appRequest.approval.approvalStatus.toString() == 'REQUESTED'}">
<form th:action="@{/myapikey/api_key_request/cancel}" method="post">
<input type="hidden" name="id" th:value="${appRequest.id}">
<button type="submit" class="btn_del">취소</button>
</form>
</div>
<div class="btn_inventory btn_mtop">
<a th:href="@{/myapikey/api_key_request/prod_history}" class="common_btn_type_1">목록</a>
</div>
</div>
</div>
<div id="copyToast" class="toast" style="position: absolute; top: 20px; right: 20px;" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header">
<strong class="me-auto">알림</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div class="toast-body">
복사되었습니다
</div>
</div>
</section>
<script layout:fragment="script">
</script>
</body>
</html>
@@ -1,114 +0,0 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kbank_apikey_layout}">
<body>
<section class="content" layout:fragment="contentFragment">
<div class="content_wrap">
<div class="sub_title3">
<h2 class="title">인증키 신청 목록</h2>
</div>
<div class="inner i_cs h_inner8">
<div class="tab-wrap">
<div class="tabs">
<ul class="tab_nav tab_bt">
<li>
<a th:href="@{/myapikey/api_key_request/history}">개발</a>
</li>
<li class="active">
<a th:href="@{/myapikey/api_key_request/prod_history}">운영</a>
</li>
</ul>
<div class="tab active">
<div class="tb_board_list">
<div class="table_box">
<div class="pc-only">
<table class="table table_min" cellspacing="0" cellpadding="0" width="100%">
<colgroup>
<col width="60px">
<col width="150px">
<col width="90px">
<col width="90px">
<col width="90px">
<col width="250px">
<col width="100px">
</colgroup>
<thead>
<tr>
<th>No</th>
<th>신청명</th>
<th>신청상태</th>
<th>신청일</th>
<th>처리일자</th>
<th>신청사유</th>
<th>사용 API 수</th>
</tr>
</thead>
<tbody>
<tr th:each="request, status : ${requests}">
<td th:text="${status.index + 1}">1</td>
<td><a th:href="@{/myapikey/api_key_request/prod_detail(id=${request.id})}" class="btn btn-sm btn-primary" th:text="'[' + ${request.clientName} + '] ' + ${request.type.description}"></a></td>
<td><span th:if="${request.approval != null}" th:text="${request.approval.approvalStatus.description}"></span></td>
<td><span th:if="${request.approval != null}" th:text="${#temporals.format(request.approval.createdDate, 'yyyy-MM-dd')}"></span></td>
<td><span th:if="${request.approval != null}" th:text="${#temporals.format(request.approval.approvalDate, 'yyyy-MM-dd')}"></span></td>
<td th:text="${request.reason}">1</td>
<td th:text="${#lists.size(#strings.arraySplit(request.apiList, ',').?[!#strings.isEmpty(#this)])}">0</td>
</tr>
</tbody>
</table>
</div>
<div class="m-only">
<table class="table table_min" cellspacing="0" cellpadding="0" width="100%">
<colgroup>
<col width="60px">
<col width="110px">
<col width="90px">
<col width="90px">
<col width="250px">
<col width="100px">
</colgroup>
<thead>
<tr>
<th>No</th>
<th>신청명</th>
<th>신청상태</th>
<th>신청일</th>
<th>신청사유</th>
<th>사용 API 수</th>
</tr>
</thead>
<tbody>
<tr th:each="request, status : ${requests}">
<td th:text="${status.index + 1}">1</td>
<td><a th:href="@{/myapikey/api_key_request/prod_detail(id=${request.id})}" class="btn btn-sm btn-primary" th:text="${request.clientName}"></a></td>
<td th:text="${request.status}">1</td>
<td th:text="${#temporals.format(request.createdDate, 'yyyy-MM-dd')}"></td>
<td th:text="${request.reason}">1</td>
<td th:text="${#lists.size(#strings.arraySplit(request.apiList, ',').?[!#strings.isEmpty(#this)])}">0</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="pagination" th:replace="~{fragment/pagination :: pagination(jsFunction='fn_select_page')}"></div>
<form name="listForm" th:action="@{/myapikey/api_key_request/history}" method="get">
<input type="hidden" id="page" name="page"/>
</form>
</div>
</div>
</div>
</div>
</div>
</section>
<th:block layout:fragment="contentScript">
<script>
function fn_select_page(pageNo) {
document.listForm.page.value = pageNo;
document.listForm.action = '[[@{/myapikey/api_key_request/history}]]';
document.listForm.submit();
}
</script>
</th:block>
</body>
</html>
@@ -0,0 +1,208 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
layout:decorate="~{layout/kjbank_base_layout}">
<head>
<title>API 키 신청 상세</title>
</head>
<body>
<th:block layout:fragment="contentFragment">
<!-- Header -->
<div class="register-header">
<h1>API Key 신청 상세</h1>
<p class="header-description">
신청하신 API Key의 상세 정보를 확인할 수 있습니다.
</p>
</div>
<section class="apikey-detail-container">
<!-- Section: 기본 정보 (From Step 1) -->
<div class="detail-section">
<h2 class="section-title">기본 정보</h2>
<div class="detail-grid">
<!-- App Icon -->
<div class="detail-item full-width">
<label class="detail-label">앱 아이콘</label>
<div class="detail-value">
<div class="app-icon-display" th:if="${appRequest.appIconFileId != null}">
<img th:src="@{/file/download(fileSn=1,fileId=${appRequest.appIconFileId})}"
alt="앱 아이콘"
class="app-icon-image">
</div>
<div class="app-icon-display" th:unless="${appRequest.appIconFileId != null}">
<div class="app-icon-placeholder">
<span>📱</span>
</div>
</div>
</div>
</div>
<!-- App Name -->
<div class="detail-item">
<label class="detail-label">앱 이름</label>
<div class="detail-value" th:text="${appRequest.clientName}">
My Application
</div>
</div>
<!-- Client ID (if exists) -->
<div class="detail-item" th:if="${appRequest.clientId != null}">
<label class="detail-label">Client ID</label>
<div class="detail-value" th:text="${appRequest.clientId}">
client-id-12345
</div>
</div>
<!-- App Description -->
<div class="detail-item full-width">
<label class="detail-label">앱 설명</label>
<div class="detail-value" th:text="${appRequest.appDescription}">
This is a sample app description.
</div>
</div>
<!-- Callback URL -->
<div class="detail-item full-width">
<label class="detail-label">Callback URL</label>
<div class="detail-value" th:text="${appRequest.callbackUrl}">
https://example.com/callback
</div>
</div>
<!-- IP Whitelist -->
<div class="detail-item full-width" th:if="${appRequest.ipWhitelist != null and !appRequest.ipWhitelist.isEmpty()}">
<label class="detail-label">IP 화이트리스트</label>
<div class="detail-value">
<div class="ip-list">
<span class="ip-tag" th:each="ip : ${#strings.arraySplit(appRequest.ipWhitelist, ',')}">
[[${ip.trim()}]]
</span>
</div>
</div>
</div>
<!-- Created Date -->
<div class="detail-item">
<label class="detail-label">신청일시</label>
<div class="detail-value" th:text="${#temporals.format(appRequest.createdDate, 'yyyy.MM.dd HH:mm')}">
2024.01.01 12:00
</div>
</div>
<div class="detail-item" th:if="${appRequest.approval != null}">
<label class="detail-label">승인 상태</label>
<div class="detail-value" th:text="${appRequest.approval.approvalStatus.description}">
승인 대기
</div>
</div>
<div class="detail-item" th:if="${appRequest.approval != null and appRequest.approval.approvalDate != null}">
<label class="detail-label">승인일시</label>
<div class="detail-value" th:text="${#temporals.format(appRequest.approval.approvalDate, 'yyyy.MM.dd HH:mm')}">
2024.01.01 12:00
</div>
</div>
<div class="detail-item full-width" th:if="${appRequest.approval != null and appRequest.reason != null and !appRequest.reason.isEmpty()}">
<label class="detail-label">사유</label>
<div class="detail-value" th:text="${appRequest.reason}">
Approval reason
</div>
</div>
</div>
</div>
<!-- Section: API 정보 (From Step 2) -->
<div class="detail-section">
<h2 class="section-title">선택된 API</h2>
<!-- API List -->
<div class="api-detail-list">
<div class="api-list-item" th:each="api : ${appRequest.apiSpecList}">
<span class="api-method"
th:classappend="${api.apiMethod == 'GET' ? 'method-get' :
(api.apiMethod == 'POST' ? 'method-post' :
(api.apiMethod == 'PUT' ? 'method-put' :
(api.apiMethod == 'DELETE' ? 'method-delete' : 'method-other')))}">
[[${api.apiMethod}]]
</span>
<span class="api-name" th:text="${api.apiName}">API Name</span>
<span class="api-status status-pending">대기중</span>
</div>
<!-- Empty State -->
<div class="empty-state" th:if="${appRequest.apiSpecList == null or appRequest.apiSpecList.isEmpty()}">
<div class="empty-icon">📦</div>
<p>선택된 API가 없습니다.</p>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="detail-actions">
<button type="button"
sec:authorize="hasRole('ROLE_API_KEY_REQUEST')"
class="btn-cancel"
th:if="${appRequest.approval != null and appRequest.approval.approvalStatus != null and
(appRequest.approval.approvalStatus.toString() == 'PENDING' or appRequest.approval.approvalStatus.toString() == 'REQUESTED')}"
th:data-request-id="${appRequest.id}"
onclick="cancelRequestById(this)">
신청 취소
</button>
<a th:href="@{/myapikey}" class="btn-secondary">
목록으로
</a>
</div>
</section>
</th:block>
<th:block layout:fragment="contentScript">
<script th:inline="javascript">
/*<![CDATA[*/
// Cancel request function - called from button with data-request-id attribute
function cancelRequestById(button) {
var requestId = $(button).data('request-id');
if (!confirm('정말로 이 신청을 취소하시겠습니까?')) {
return;
}
$('.loading-overlay').show();
$.ajax({
url: '/myapikey/api_key_request/cancel',
type: 'POST',
data: {id: requestId},
dataType: 'json',
headers: {
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
}
}).done(function(response) {
if (response.success) {
alert(response.message || '신청이 취소되었습니다.');
window.location.href = /*[[@{/myapikey}]]*/ '/myapikey';
} else {
alert(response.message || '신청 취소 중 오류가 발생했습니다.');
$('.loading-overlay').hide();
}
}).fail(function(xhr, status, error) {
alert('신청 취소 중 오류가 발생했습니다.');
console.error('Cancel error:', error);
$('.loading-overlay').hide();
});
}
/*]]>*/
</script>
</th:block>
</body>
</html>
@@ -0,0 +1,320 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
layout:decorate="~{layout/kjbank_base_layout}">
<head>
<title>API 키 상세</title>
</head>
<body>
<th:block layout:fragment="contentFragment">
<!-- Header -->
<div class="register-header">
<h1>API Key 상세</h1>
<p class="header-description">
발급된 API Key의 상세 정보를 확인할 수 있습니다.
</p>
</div>
<section class="apikey-detail-container">
<!-- Section 1: 앱 기본 정보 -->
<div class="detail-section">
<h2 class="section-title">앱 정보</h2>
<div class="detail-grid">
<!-- App Icon -->
<div class="detail-item full-width">
<label class="detail-label">앱 아이콘</label>
<div class="detail-value">
<div class="app-icon-display" th:if="${apiKey.appIconFileId != null}">
<img th:src="@{/file/download(fileSn=1,fileId=${apiKey.appIconFileId})}"
alt="앱 아이콘"
class="app-icon-image">
</div>
<div class="app-icon-display" th:unless="${apiKey.appIconFileId != null}">
<div class="app-icon-placeholder">
<span>📱</span>
</div>
</div>
</div>
</div>
<!-- App Name -->
<div class="detail-item">
<label class="detail-label">앱 이름</label>
<div class="detail-value" th:text="${apiKey.clientname}">
My Application
</div>
</div>
<!-- App Status -->
<div class="detail-item">
<label class="detail-label">앱 상태</label>
<div class="detail-value">
<span class="status-indicator"
th:classappend="${apiKey.appstatus == '1' ? 'status-active' : 'status-inactive'}">
<span class="status-dot"></span>
<span th:text="${apiKey.appstatus == '1' ? '활성화' : '비활성화'}">활성화</span>
</span>
</div>
</div>
<!-- App Description -->
<div class="detail-item full-width">
<label class="detail-label">앱 설명</label>
<div class="detail-value" th:text="${apiKey.appDescription != null ? apiKey.appDescription : '설명이 없습니다.'}">
This is a sample app description.
</div>
</div>
</div>
</div>
<!-- Section 2: 인증 상세 정보 -->
<div class="detail-section">
<h2 class="section-title">인증 정보</h2>
<div class="detail-grid">
<!-- Client ID -->
<div class="detail-item">
<label class="detail-label">Client ID</label>
<div class="detail-value">
<div class="secret-box">
<code class="credential-code" th:text="${apiKey.clientid}">client-id-12345</code>
<button type="button" class="btn-copy" th:data-secret="${apiKey.clientid}" onclick="copyToClipboardFromButton(this)">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
복사
</button>
</div>
</div>
</div>
<!-- Client Secret -->
<div class="detail-item">
<label class="detail-label">Client Secret</label>
<div class="detail-value">
<!-- Hidden Secret (shown initially) -->
<div class="secret-box" id="hiddenSecretBox">
<code class="credential-code">••••••••••••••••••••</code>
<button type="button" class="btn-copy" onclick="showPasswordPrompt()">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
보기
</button>
</div>
<!-- Actual Secret (hidden initially) -->
<div class="secret-box" id="revealedSecretBox" style="display: none;">
<code class="credential-code" th:text="${apiKey.clientsecret}">secret-key-67890</code>
<button type="button" class="btn-copy" th:data-secret="${apiKey.clientsecret}" onclick="copyToClipboardFromButton(this)">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
복사
</button>
</div>
</div>
</div>
<!-- Callback URL -->
<div class="detail-item full-width">
<label class="detail-label">Callback URL</label>
<div class="detail-value" th:text="${apiKey.redirecturi != null ? apiKey.redirecturi : '설정되지 않음'}">
https://example.com/callback
</div>
</div>
<!-- IP Whitelist -->
<div class="detail-item full-width" th:if="${apiKey.allowedips != null}">
<label class="detail-label">IP 화이트리스트</label>
<div class="detail-value">
<div class="ip-list">
<span class="ip-tag" th:each="ip : ${#strings.arraySplit(apiKey.allowedips, ',')}">
[[${ip.trim()}]]
</span>
</div>
</div>
</div>
<!-- 등록된 API -->
<div class="detail-item full-width">
<label class="detail-label">등록된 API</label>
<div class="detail-value">
<!-- API List -->
<div class="api-detail-list">
<div class="api-list-item" th:each="api : ${apiKey.apiList}">
<span class="api-method method-other">
API
</span>
<span class="api-name" th:text="${api.apiDesc}">API Name</span>
<span class="api-status status-active">활성</span>
</div>
<!-- Empty State -->
<div class="empty-state" th:if="${apiKey.apiList == null or apiKey.apiList.isEmpty()}">
<div class="empty-icon">📦</div>
<p>등록된 API가 없습니다.</p>
</div>
</div>
</div>
</div>
<!-- Modified Date -->
<div class="detail-item">
<label class="detail-label">최종 수정일자</label>
<div class="detail-value" th:text="${#temporals.format(apiKey.modifiedon, 'yyyy.MM.dd HH:mm')}">
2024.01.01 12:00
</div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="detail-actions">
<a sec:authorize="hasRole('ROLE_API_KEY_REQUEST')"
class="btn-action btn-primary"
th:href="@{/myapikey/modify/step1(clientId=${apiKey.clientid})}">
수정
</a>
<button type="button"
sec:authorize="hasRole('ROLE_API_KEY_REQUEST')"
class="btn-action btn-danger"
th:data-client-id="${apiKey.clientid}"
onclick="deleteApiKeyFromButton(this)">
인증키 삭제
</button>
<a th:href="@{/myapikey}" class="btn-secondary">
목록으로
</a>
</div>
</section>
</th:block>
<th:block layout:fragment="contentScript">
<script th:inline="javascript">
/*<![CDATA[*/
$(document).ready(function () {
activateSelect();
});
// Show password prompt for viewing client secret
function showPasswordPrompt() {
customPopups.showPasswordInput({
title: 'Client Secret 조회',
message: '보안을 위해 비밀번호를 입력해주세요.',
onConfirm: function(password) {
// Verify password via AJAX
$.ajax({
url: /*[[@{/myapikey/verify-password}]]*/ '/myapikey/verify-password',
type: 'POST',
data: { password: password },
headers: {
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
},
success: function(response) {
if (response.success) {
// Hide password popup
customPopups.hidePasswordInput();
// Reveal the client secret
$('#hiddenSecretBox').hide();
$('#revealedSecretBox').fadeIn(300);
} else {
// Show error message
customPopups.showPasswordError(response.message || '비밀번호가 일치하지 않습니다.');
}
},
error: function() {
customPopups.showPasswordError('오류가 발생했습니다. 다시 시도해주세요.');
}
});
},
onCancel: function() {
// User cancelled - do nothing
}
});
}
// Copy to clipboard function - called from button with data-secret attribute
function copyToClipboardFromButton(button) {
var text = $(button).data('secret');
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function() {
alert('클립보드에 복사되었습니다.');
}).catch(function(err) {
console.error('Failed to copy:', err);
fallbackCopy(text);
});
} else {
fallbackCopy(text);
}
}
function fallbackCopy(text) {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
alert('클립보드에 복사되었습니다.');
} catch (err) {
alert('복사에 실패했습니다.');
}
document.body.removeChild(textArea);
}
// Delete API Key function - called from button with data-client-id attribute
function deleteApiKeyFromButton(button) {
var clientId = $(button).data('client-id');
if (!confirm('정말로 이 인증키를 삭제하시겠습니까?')) {
return;
}
$('.loading-overlay').show();
const requestData = {
clientId: clientId
};
$.ajax({
url: '/myapikey/api_key_delete',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(requestData),
headers: {
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
}
}).done(function (response) {
alert(response.msg || 'API Key가 삭제되었습니다.');
window.location.href = /*[[@{/myapikey}]]*/ '/myapikey';
}).fail(function (jqXHR, textStatus, errorThrown) {
alert('API 삭제 요청 중 오류가 발생했습니다: ' + errorThrown);
}).always(function () {
$('.loading-overlay').hide();
});
}
/*]]>*/
</script>
</th:block>
</body>
</html>
@@ -15,9 +15,9 @@
<meta content="max-age=0, public" http-equiv="Cache-Control"/>
<meta content="index, follow" name="robots"/>
<meta content="https://www.eactive.co.kr/" property="og:url"/>
<link rel="icon" type="image/ico" th:href="@{/favicon_16px.png}">
<link rel="stylesheet" type="text/css" th:href="@{/css/style2.css}">
<link rel="stylesheet" type="text/css" th:href="@{/css/common2.css}">
<link rel="icon" type="image/ico" th:href="@{/favicon.ico}">
<link rel="stylesheet" type="text/css" th:href="@{/css/main.css}">
<!-- <link rel="stylesheet" type="text/css" th:href="@{/css/common2.css}">-->
</head>
<body>
<div id="wrap" class="wrap_top">
@@ -1,628 +0,0 @@
<div th:fragment="prodApiKeyRequest" class="pop_dim">
<div class="inner">
<div class="popup-content pop_w2">
<div class="inner_txt">
<div class="title">
<h2>인증키 운영 전환 신청</h2>
</div>
</div>
<div class="form_type">
<div class="info1 form_top" style="margin-top: 10px;">
<p class="title w_tit">
<span class="dot">API키 이름</span>
</p>
<div class="info_line info_add">
<div class="info_box1 w_inp4">
<input type="text" style="max-width: 300px;" name="name" id="appRequestProdClientName" class="common_input_type_1 h_inp2" placeholder="API키 이름 입력">
</div>
</div>
</div>
</div>
<div class="infor api_dot top">
<ul>
<li>
<p>인증키가 없을 경우, 승인 시 인증키는 신규로 발급 됩니다.</p>
</li>
</ul>
</div>
<div class="form_type" id="apiKeySelect">
<div class="info1 form_top" style="margin-top: 10px;">
<p class="title w_tit">
<span class="dot">API키 선택</span>
</p>
<div class="info_line info_add">
<div class="info_box1 w_inp4">
<div class="custom_select select_w h_inp" style="width: 300px;">
<input type="hidden" id="clientId" />
<div class="select_selected h_inp" id="selectedDevApiKey">API키 선택</div>
<ul class="common_selecttype select_items select_hide" id="devApiKeyList">
</ul>
</div>
</div>
</div>
</div>
</div>
<p class="pop_text l_text m-only">API</p>
<div class="board_search sh_box top2">
<p class="pop_text l_text pc-only">API</p>
<div class="search_txt ser_api w_inp">
<input type="text" class="common_input_type_1 h_inp2" id="prod_api_keyword" placeholder="API 검색">
</div>
<div class="btn_wrap search_btn">
<button type="button" class="btn_search" id="prod_api_search">검색</button>
<i class="i_search"></i>
</div>
<p class="pop_text l_text pop_text2 pc-only" style="margin-left: 145px;">운영 이관 신청 API</p>
</div>
<div class="tb_conbox top2">
<div class="box table_type2 top_type">
<table class="table_col table_min tb_bdtop" id="prodSourceTable" cellspacing="0" cellpadding="0" width="100%">
<colgroup>
<col width="60px">
<col width="140px">
<col width="230px">
</colgroup>
<thead>
<tr>
<th>
<div class="total_wrap">
<span class="inp_check b_tit">
<input type="checkbox" class="agree_all" id="selectAllProdSourceTable" name="selectall" data-group="total">
<label for="selectAllProdSourceTable"></label>
</span>
</div>
</th>
<th>서비스</th>
<th>API</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
<div class="box pop_arrow m-only">
<a href="#none" class="add_apis"><img th:src="@{/img/icon/Icon_arrow_btn_down.png}" alt="올리기"></a>
<a href="#none" class="remove_apis"><img th:src="@{/img/icon/Icon_arrow_btn_up.png}" alt="내리기"></a>
</div>
<div class="box pop_arrow pc-only">
<p>
<a href="#none" class="add_apis"><img th:src="@{/img/icon/icon_arrow_btn_r.png}" alt="넣기"></a>
</p>
<p>
<a href="#none" class="remove_apis"><img th:src="@{/img/icon/icon_arrow_btn_l.png}" alt="빼기"></a>
</p>
</div>
<p class="pop_text l_text2 pt_top m-only">운영 이관 신청 API</p>
<div class="box table_type2 top_type">
<table class="table_col table_min tb_bdtop" id="prodTargetTable" cellspacing="0" cellpadding="0" width="100%">
<colgroup>
<col width="60px">
<col width="140px">
<col width="230px">
</colgroup>
<thead>
<tr>
<th>
<div class="total_wrap">
<span class="inp_check b_tit">
<input type="checkbox" class="agree_all" id="selectAllProdTargetTable" name="selectall2" data-group="total2">
<label for="selectAllProdTargetTable"></label>
</span>
</div>
</th>
<th>서비스</th>
<th>API</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
<p class="pop_text l_text2">신청사유</p>
<div class="pop_textbox">
<textarea name="reason" id="prod_reason" rows="5" cols="30" class="common_textareaType_1" placeholder="신청사유를 입력해 주세요."></textarea>
</div>
<div class="pop_btnbox">
<button class="popup_button_gray btn_cancel">취소</button>
<button class="popup_button_blue request_prod_apis">운영 전환 신청</button>
</div>
<a href="#none" class="btn_close" style="margin-top:0">
<img th:src="@{/img/icon/icon_close.png}" alt="닫기">
</a>
</div>
</div>
</div>
<th:block th:fragment="prodApiKeyRequestScript">
<script>
$.ajaxSetup({
beforeSend: function (xhr) {
xhr.setRequestHeader('X-XSRF-TOKEN', '[[${_csrf.token}]]');
}
});
$(document).ready(function () {
(function ($) {
// Model
const model = {
apiKeys: [],
clientId: '',
sourceItems: [],
targetItems: [],
isUpdate: false,
updateSourceItemsFromApiKey: function (clientId) {
this.sourceItems = [];
const selectedApiKey = this.apiKeys.find(key => key.clientid === clientId);
console.log(selectedApiKey);
if (selectedApiKey) {
this.sourceItems = selectedApiKey.apiList.map(api => ({
apiId : api.apiId,
service: api.service || '',
apiName: api.apiDesc,
selected: false
}));
console.log(this.sourceItems);
}
},
fetchDevApiKeys: function () {
return $.ajax({
url: '/myapikey/dev',
type: 'GET',
dataType: 'json'
}).then((data) => {
if (data.length === 0) {
customPopups.showAlert(('개발 인증 키를 먼저 발급 받으세요.'));
} else {
this.apiKeys = data;
var clientId = $('#clientId').val(); //개발 인증키에서 운영 전환 신청 할 때
console.log('개발 인증키:', clientId);
if (clientId) {
$('#apiKeySelect').css('display', 'none');
model.sourceItems.forEach(item => {
item.selected = false;
item.inTarget = false;
});
model.updateSourceItemsFromApiKey(clientId);
model.targetItems = [];
view.render();
} else {
const apiKeyList = $('#devApiKeyList');
apiKeyList.empty();
const li = $('<li>').attr('data-id', '').addClass('api-item').text('API키 선택');
apiKeyList.append(li);
data.forEach(item => {
const li = $('<li>').attr('data-id', item.clientid).addClass('api-item').text(item.clientname);
apiKeyList.append(li);
});
const selectedApiKey = document.getElementById('selectedDevApiKey');
apiKeyList.on('click', function (e) {
if (e.target && e.target.matches('li.api-item')) {
const selectedName = e.target.textContent;
const selectedId = e.target.getAttribute('data-id');
$('#clientId').val(selectedId);
selectedApiKey.textContent = selectedName;
model.sourceItems.forEach(item => {
if (!model.targetItems.some(tItem => tItem.apiId === item.apiId)) {
item.selected = false;
item.inTarget = false;
}
});
model.updateSourceItemsFromApiKey(selectedId);
view.render();
}
});
}
}
}).catch((error) => {
console.error('Error fetching source items: ', error);
});
},
moveSelectedToTarget: function () {
const selectedItems = this.sourceItems.filter(item => item.selected && !item.inTarget);
selectedItems.forEach(item => {
const isDuplicate = this.targetItems.some(targetItem => targetItem.apiId === item.apiId);
if (!isDuplicate){
item.selected = false;
item.inTarget = true;
this.targetItems.push({
apiId: item.apiId,
service: item.service,
apiName: item.apiName,
selected: true
});
}
});
},
moveSelectedToSource: function () {
const selectedItems = this.targetItems.filter(item => item.selected);
selectedItems.forEach(item => {
const sourceItem = this.sourceItems.find(sItem => sItem.apiId === item.apiId);
if (sourceItem) {
sourceItem.inTarget = false;
sourceItem.selected = false;
}
});
this.targetItems = this.targetItems.filter(item => !item.selected);
}
};
// View
const view = {
init: function () {
this.prodSourceTableBody = $('#prodSourceTable tbody');
this.prodTargetTableBody = $('#prodTargetTable tbody');
this.selectAllSourceCheckbox = $('#selectAllProdSourceTable');
this.selectAllTargetCheckbox = $('#selectAllProdTargetTable');
this.nameInput = $('#appRequestProdClientName');
this.reasonTextarea = $('#prod_reason');
this.bindEvents();
},
bindEvents: function () {
this.selectAllSourceCheckbox.on('change', function () {
console.log('select all');
const isChecked = $(this).is(':checked');
model.sourceItems.forEach(item => {
const keyword = $('#prod_api_keyword').val().trim().toLowerCase();
if (keyword !== '') {
item.selected = isChecked && (item.apiName.toLowerCase().includes(keyword) ||
(item.service != null && item.service.toLowerCase().includes(keyword)));
} else {
item.selected = isChecked;
}
});
view.renderProdSourceTable();
});
this.selectAllTargetCheckbox.on('change', function () {
console.log('select all target')
const isChecked = $(this).is(':checked');
model.targetItems.forEach(item => item.selected = isChecked);
view.renderProdTargetTable();
});
// Add APIs button
$('.add_apis').on('click', function (event) {
event.preventDefault();
const selectedCount = model.sourceItems.filter(item => item.selected && !item.inTarget).length;
// if (selectedCount === 0) {
// customPopups.showAlert('선택된 API가 없습니다.');
// return;
// }
model.moveSelectedToTarget();
view.render();
});
// Remove APIs button
$('.remove_apis').on('click', function (event) {
event.preventDefault();
const selectedCount = model.targetItems.filter(item => item.selected && !item.inTarget).length;
// if (selectedCount === 0) {
// customPopups.showAlert('선택된 API가 없습니다.');
// return;
// }
model.moveSelectedToSource();
view.render();
});
// Request APIs button
$('.request_prod_apis').off('click');
$('.request_prod_apis').on('click', function (event) {
event.preventDefault();
controller.requestApis();
});
$('.btn_change_prod_api').off('click');
$('.btn_change_prod_api').on('click', function (event) {
event.preventDefault();
model.clientId = $('#prodClientId').val();
model.isUpdate = true;
view.nameInput.val($('#prodClientName').val());
model.fetchDevApiKeys().then(() => {
controller.initializeExistingApis(function () {
controller.openPopup();
});
});
});
// Open popup
$('.btn_request_prod_api').on('click', function (event) {
event.preventDefault();
controller.openPopup();
});
// Close popup
$('.btn_close, .btn_cancel').on('click', function (event) {
event.preventDefault();
controller.closePopup();
});
// Search functionality
$('.btn_search').on('click', function (event) {
event.preventDefault();
controller.searchApis();
});
// Optional: Implement search on enter key
$('.search_txt input').on('keyup', function (event) {
if (event.keyCode === 13) {
controller.searchApis();
}
});
},
render: function () {
this.renderProdSourceTable();
this.renderProdTargetTable();
this.updateSelectAllCheckbox('source');
this.updateSelectAllCheckbox('target');
},
bindCheckboxEvents: function(checkbox, item, tableType){
checkbox.addClass('prod-table-checkbox');
checkbox.on('change', function(e){
if(!$(this).hasClass('prod-table-checkbox')){
return;
}
console.log(`Prod ${tableType} checkbox changed ->`, this.checked);
const isChecked = $(this).is(':checked');
if(tableType === 'source'){
const sourceItem = model.sourceItems.find(sItem => sItem.apiId === item.apiId);
if(sourceItem){
sourceItem.selected = isChecked;
$(`#prod_agree_ch_source_${item.apiId}`).prop('checked', isChecked);
}
view.updateSelectAllCheckbox('source');
} else {
const targetItem = model.targetItems.find(tItem => tItem.apiId === item.apiId);
if(targetItem){
targetItem.selected = isChecked;
$(`#prod_agree_ch_target_${item.apiId}`).prop('checked', isChecked);
}
view.updateSelectAllCheckbox('target');
}
});
},
createTableRow: function(item, tableType){
const checkboxId = `prod_agree_ch_${tableType}_${item.apiId}`;
const checkboxName = `prod_${tableType}Group`;
const tr = $('<tr>');
const tdCheckbox = $('<td>');
const divSuvCk = $('<div>').addClass('suv_ck');
const ul = $('<ul>');
const li = $('<li>');
const spanInpCheck = $('<span>').addClass('inp_check s_tit');
const checkbox = $('<input>', {
type: 'checkbox',
id: checkboxId,
name: checkboxName,
'data-api-id': item.apiId,
checked: item.selected,
class: 'prod-table-checkbox'
});
const label = $('<label>', {
for: checkboxId
});
this.bindCheckboxEvents(checkbox, item, tableType);
//assemble DOM
spanInpCheck.append(checkbox, label);
li.append(spanInpCheck);
ul.append(li);
divSuvCk.append(ul);
tdCheckbox.append(divSuvCk);
const tdService = $('<td>').text(item.service || '');
const tdApiName = $('<td>').text(item.apiName);
tr.append(tdCheckbox, tdService, tdApiName);
return tr;
},
renderProdSourceTable: function () {
const keyword = $('#prod_api_keyword').val().trim().toLowerCase();
this.prodSourceTableBody.empty();
const targetApiIds = new Set(model.targetItems.map(item => item.apiId));
// Filter source items
const filteredItems = model.sourceItems.filter(item => {
const notInTarget = !targetApiIds.has(item.apiId) && !item.inTarget;
if (keyword !== '') {
return notInTarget && (
item.apiName.toLowerCase().includes(keyword) ||
(item.service && item.service.toLowerCase().includes(keyword))
);
}
return notInTarget;
});
// Render filtered items
filteredItems.forEach(item => {
const row = view.createTableRow(item, 'source');
view.prodSourceTableBody.append(row);
});
},
renderProdTargetTable: function () {
this.prodTargetTableBody.empty();
model.targetItems.forEach(item => {
const row = this.createTableRow(item, 'target');
this.prodTargetTableBody.append(row);
});
},
updateSelectAllCheckbox: function (tableType) {
if (tableType === 'source') {
const allChecked = model.sourceItems.length > 0 && model.sourceItems.every(item => item.selected);
$('#selectAllProdSourceTable').prop('checked', allChecked);
} else if (tableType === 'target') {
const allChecked = model.targetItems.length > 0 && model.targetItems.every(item => item.selected);
$('#selectAllProdTargetTable').prop('checked', allChecked);
}
},
resetForm: function () {
this.nameInput.val('');
this.reasonTextarea.val('');
}
};
// Controller
const controller = {
init: function () {
view.init();
model.fetchDevApiKeys().then(() => {
view.render();
});
},
openPopup: function () {
this.init();
$('#apiKeyProdRequestPopup').show();
},
closePopup: function () {
$('#apiKeyProdRequestPopup').hide();
},
initializeExistingApis: function (callback) {
let requestData = {clientId : model.clientId};
$.ajax({
url: '/myapikey/prod',
type: 'POST',
dataType: 'json',
contentType: 'application/json',
data: JSON.stringify(requestData)
}).then((data) => {
const existingApis = [];
data.apiList.forEach(function (api) {
existingApis.push({
apiId: api.apiId,
service: api.service,
apiName: api.apiDesc,
selected: true
});
const sourceItem = model.sourceItems.find(sItem => sItem.apiId === api.apiId);
if (sourceItem) {
sourceItem.inTarget = true;
}
});
model.targetItems = existingApis;
view.render();
callback();
});
},
requestApis: function () {
const name = view.nameInput.val().trim();
const reason = view.reasonTextarea.val().trim();
const apis = model.targetItems.filter(item => item.selected).map(item => item.apiId).join(',');
if (!name) {
customPopups.showAlert('이름을 입력해 주세요.');
return;
}
if (apis.length === 0) {
customPopups.showAlert('API를 선택해 주세요.');
return;
}
if (!reason) {
customPopups.showAlert('신청사유를 입력해 주세요.');
return;
}
let refClient = $('#clientId').val();
const requestData = {
clientName: name,
apiList: apis,
reason: reason,
refClient: refClient
};
if (model.isUpdate) {
requestData.clientId = model.clientId;
}
$('.loading-overlay').show();
$.ajax({
url: '/myapikey/api_key_request_prod',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(requestData)
}).done(function (response) {
view.resetForm();
model.targetItems = [];
view.render();
controller.closePopup();
customPopups.showAlert(response.msg, function () {
window.location.href = '/myapikey/api_key_request/prod_history';
});
}).fail(function (jqXHR, textStatus, errorThrown) {
customPopups.showAlert('API 신청 중 오류가 발생했습니다: ' + errorThrown);
}).always(function () {
$('.loading-overlay').hide();
});
},
searchApis: function () {
view.renderProdSourceTable();
}
};
controller.init(); // We initialize when the popup is opened
})(jQuery);
});
</script>
</th:block>
@@ -7,193 +7,137 @@
</head>
<body>
<div th:fragment="headerFragment">
<h1 class="logo pc-only"><a th:href="@{/}"><span class="blind">kbank</span></a></h1>
<h1 class="sub_title m-only">
<p th:text="${pageName}">API Portal</p>
</h1>
<div class="home">
<a th:href="@{/}"><span class="blind">home</span></a>
</div>
<button type="button" class="btn-mobilemenu m-only">
<span>모바일메뉴 열기</span>
</button>
<div class="m-nav m-only">
<h1><a class="logo2" th:href="@{/}"><span class="blind">Kbank</span></a></h1>
<div>
<a class="home2" th:href="@{/}"><span class="blind">home</span></a>
</div>
<div class="login_btn_group">
<div class="header_btn_group">
<div class="login_wrap flex_no" sec:authorize="isAnonymous()">
<!-- 로그인, 회원가입 -->
<div class="login_prev">
<button th:href="@{/login}" class="btn btn_loginbt">로그인</button>
<button th:href="@{/signup}" class="btn btn_memberbt">회원가입</button>
<div th:fragment="headerFragment" class="header-container">
<!-- Unified Header -->
<header id="page-header">
<div class="header-content">
<!-- Logo -->
<h1 class="logo">
<a th:href="@{/}"><span class="blind">kbank</span></a>
</h1>
<!-- Mobile-only Subtitle -->
<div class="sub_title m-only">
<p th:text="${pageName}">API Portal</p>
</div>
<!-- Mobile Home Button -->
<div class="home m-only">
<a th:href="@{/}"><span class="blind">home</span></a>
</div>
<!-- Mobile Menu Toggle -->
<button type="button" class="btn-mobilemenu m-only">
<span>모바일메뉴 열기</span>
</button>
<!-- Unified Navigation -->
<nav class="main-nav m-nav">
<!-- Login/Profile Section (Mobile) -->
<div class="nav-header m-only">
<div class="login_btn_group">
<div class="header_btn_group">
<div class="login_wrap flex_no" sec:authorize="isAnonymous()">
<div class="login_prev">
<a th:href="@{/login}" class="btn btn_loginbt">로그인</a>
<a th:href="@{/signup}" class="btn btn_memberbt">회원가입</a>
</div>
</div>
<div class="login_wrap" sec:authorize="isAuthenticated()">
<div class="profile_type">
<a th:href="@{/mypage}">
<p>[[${#authentication.principal.userName}]]님</p>
<span>즐거운 하루 되세요!</span>
</a>
</div>
<a th:href="@{/actionLogout.do}" class="btn btn_logout">로그아웃</a>
</div>
</div>
</div>
</div>
<div class="login_wrap" sec:authorize="isAuthenticated()">
<div class="profile_type">
<a th:href="@{/mypage}">
<p>[[${#authentication.principal.userName}]]님</p>
<span>즐거운 하루 되세요!</span>
</a>
</div>
<button type="button" th:href="@{/actionLogout.do}" class="btn btn_logout">로그아웃</button>
</div>
</div>
</div>
<nav>
<ul class="m-nav-cs">
<li>
<a th:href="@{/intro}">
<img th:src="@{/img/icon/icon_gnb_info.png}" alt="">
안내
</a>
<ul class="mc-sub-nav">
<li>
<a th:href="@{/intro}">API Portal 소개</a>
</li>
<li><a th:href="@{/guide}">이용 안내</a></li>
</ul>
</li>
<li>
<a th:href="@{/apis}">
<img th:src="@{/img/icon/icon_gnb_api.png}" alt="">
API
</a>
<ul class="mc-sub-nav">
<li><a th:href="@{/apis}">API 목록</a></li>
<li><a sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" th:href="@{/apis/apikey/new}">API키 신청</a></li>
<li><a sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" th:href="@{/apis/apikey/prod}">운영 API키 신청</a></li>
</ul>
</li>
<li>
<a href="#none">
<img th:src="@{/img/icon/icon_gnb_code.png}" alt="테스트 베드">
테스트 베드
</a>
<ul class="mc-sub-nav">
<li><a th:href="@{/apis/testbed}">테스트베드</a></li>
</ul>
</li>
<li>
<a href="#none">
<img th:src="@{/img/icon/icon_gnb_customer.png}" alt="">
고객지원
</a>
<ul class="mc-sub-nav">
<li><a th:href="@{/portalnotice}">공지사항</a></li>
<li><a th:href="@{/faq_list}">FAQ</a></li>
<li><a th:href="@{/inquiry}">Q&amp;A</a>
<li><a th:href="@{/partnership}">사업제휴</a></li>
</ul>
</li>
<li sec:authorize="isAuthenticated()" >
<a>
<img th:src="@{/img/icon/icon_gnb_mypage.png}" alt="">
마이페이지
</a>
<ul class="mc-sub-nav">
<li sec:authorize="hasRole('ROLE_DASHBOARD')"><a th:href="@{/dashboard}">대시보드</a></li>
<li sec:authorize="hasRole('ROLE_USER_MANAGER')"><a th:href="@{/users}">이용자 관리</a></li>
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/myapikey}">인증 키 관리</a></li>
<li sec:authorize="hasRole('ROLE_API_KEY_REQUEST_VIEW')"><a th:href="@{/myapikey/api_key_request/history}">인증키 신청 목록</a></li>
<li><a th:href="@{/mypage}">내 정보 관리</a></li>
<li><a th:href="@{/change_password}">비밀번호 변경</a></li>
</ul>
</li>
</ul>
</nav>
<div class="info_wrap">
<p class="tit1">
<a th:href="@{/agreements/terms(tab='terms')}">이용약관</a> <span>/</span> <a th:href="@{/agreements/terms(tab='privacy')}">개인정보처리방침</a>
</p>
<p class="tit2">
<span>고객센터 : 1522-1000</span>
<span>해외 : +82-2-3778-9111</span>
</p>
</div>
</div>
<div id="nav">
<div id="main_nav">
<ul>
<li><a th:href="@{/intro}">안내</a></li>
<li><a th:href="@{/apis}">API</a></li>
<li><a th:href="@{/apis/testbed}">테스트베드</a></li>
<li><a href="#">고객지원</a></li>
</ul>
</div>
<div id="sub">
<div id="sub_menu">
<ul class="menu">
<li><a th:href="@{/intro}">API Portal 소개</a></li>
<li><a th:href="@{/guide}">이용 안내</a></li>
</ul>
<ul class="menu">
<li><a th:href="@{/apis}">API 목록</a></li>
<li><a sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" th:href="@{/apis/apikey/new}">API키 신청</a></li>
<li><a sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" th:href="@{/apis/apikey/prod}">운영 API키 신청</a></li>
</ul>
<ul class="menu">
<li><a th:href="@{/apis/testbed}">테스트베드</a></li>
</ul>
<ul class="menu">
<li><a th:href="@{/portalnotice}">공지사항</a></li>
<li><a th:href="@{/faq_list}">FAQ</a></li>
<li><a th:href="@{/inquiry}">Q&amp;A</a></li>
<li><a th:href="@{/partnership}">사업제휴</a></li>
</ul>
</div>
</div>
<div id="login_group">
<!-- 로그인하기전 -->
<ul sec:authorize="isAnonymous()" class="login_type">
<li><a th:href="@{/login}">로그인</a></li>
</ul>
<!-- 로그인 후 -->
<ul sec:authorize="isAuthenticated()" class="logout_type">
<li class="icon line"><a th:href="@{/mypage}">[[${#authentication.principal.userName}]]님</a></li>
<li>
<span>|</span>
</li>
<li><a th:href="@{/actionLogout.do}">로그아웃</a></li>
<li>
<span>|</span>
</li>
<li class="mp_arrow">
<a th:href="@{/dashboard}">마이페이지</a>
<div class="mp_type">
<ul class="mp_list">
<li sec:authorize="hasRole('ROLE_DASHBOARD')">
<a th:href="@{/dashboard}">대시보드</a>
</li>
<li sec:authorize="hasRole('ROLE_USER_MANAGER')">
<a th:href="@{/users}">이용자 관리</a>
</li>
<li sec:authorize="hasRole('ROLE_APP')">
<a th:href="@{/myapikey}">인증키 관리</a>
</li>
<li sec:authorize="hasRole('ROLE_API_KEY_REQUEST_VIEW')">
<a th:href="@{/myapikey/api_key_request/history}">인증키 신청 목록</a>
</li>
<li>
<a th:href="@{/mypage}">내 정보 관리</a>
</li>
<li>
<a th:href="@{/change_password}">비밀번호 변경</a>
</li>
<!-- Main Menu List -->
<ul class="nav-list">
<li class="nav-item has-submenu">
<a th:href="@{/intro}">안내</a>
<ul class="sub-menu">
<li><a th:href="@{/intro}">API Portal 소개</a></li>
<li><a th:href="@{/guide}">이용 안내</a></li>
</ul>
</div>
</li>
</ul>
<!-- // 로그인 후 -->
</li>
<li class="nav-item has-submenu">
<a th:href="@{/apis}">API</a>
<ul class="sub-menu">
<li><a th:href="@{/apis}">API 목록</a></li>
</ul>
</li>
<li class="nav-item has-submenu">
<a th:href="@{/apis/testbed}">테스트 베드</a>
<ul class="sub-menu">
<li><a th:href="@{/apis/testbed}">테스트베드</a></li>
</ul>
</li>
<li class="nav-item has-submenu">
<a href="#">고객지원</a>
<ul class="sub-menu">
<li><a th:href="@{/portalnotice}">공지사항</a></li>
<li><a th:href="@{/faq_list}">FAQ</a></li>
<li><a th:href="@{/inquiry}">Q&amp;A</a></li>
<li><a th:href="@{/partnership}">사업제휴</a></li>
</ul>
</li>
<li class="nav-item has-submenu" sec:authorize="isAuthenticated()">
<a href="#">마이페이지</a>
<ul class="sub-menu">
<li sec:authorize="hasRole('ROLE_DASHBOARD')"><a th:href="@{/dashboard}">대시보드</a></li>
<li sec:authorize="hasRole('ROLE_USER_MANAGER')"><a th:href="@{/users}">이용자 관리</a></li>
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/myapikey}">인증 키 관리</a></li>
<li sec:authorize="hasRole('ROLE_API_KEY_REQUEST_VIEW')"><a th:href="@{/myapikey/api_key_request/history}">인증키 신청 목록</a></li>
<li><a th:href="@{/mypage}">내 정보 관리</a></li>
<li><a th:href="@{/change_password}">비밀번호 변경</a></li>
</ul>
</li>
</ul>
<!-- Mobile Footer Info -->
<div class="info_wrap m-only">
<p class="tit1">
<a th:href="@{/agreements/terms(tab='terms')}">이용약관</a> <span>/</span> <a th:href="@{/agreements/terms(tab='privacy')}">개인정보처리방침</a>
</p>
<p class="tit2">
<span>고객센터 : 1522-1000</span>
<span>해외 : +82-2-3778-9111</span>
</p>
</div>
</nav>
<!-- Login/Profile Section (Desktop) -->
<div id="login_group" class="pc-only">
<ul sec:authorize="isAnonymous()" class="login_type">
<li><a th:href="@{/login}">로그인</a></li>
</ul>
<ul sec:authorize="isAuthenticated()" class="logout_type">
<li class="icon line"><a th:href="@{/mypage}">[[${#authentication.principal.userName}]]님</a></li>
<li><span>|</span></li>
<li><a th:href="@{/actionLogout.do}">로그아웃</a></li>
<li><span>|</span></li>
<li class="mp_arrow">
<a th:href="@{/dashboard}">마이페이지</a>
<div class="mp_type">
<ul class="mp_list">
<li sec:authorize="hasRole('ROLE_DASHBOARD')"><a th:href="@{/dashboard}">대시보드</a></li>
<li sec:authorize="hasRole('ROLE_USER_MANAGER')"><a th:href="@{/users}">이용자 관리</a></li>
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/myapikey}">인증키 관리</a></li>
<li sec:authorize="hasRole('ROLE_API_KEY_REQUEST_VIEW')"><a th:href="@{/myapikey/api_key_request/history}">인증키 신청 목록</a></li>
<li><a th:href="@{/mypage}">내 정보 관리</a></li>
<li><a th:href="@{/change_password}">비밀번호 변경</a></li>
</ul>
</div>
</li>
</ul>
</div>
</div>
</div>
</header>
</div>
</body>
</html>
@@ -4,4 +4,5 @@
<div th:replace="fragment/popup/emailValidationPopup :: #emailValidationPopup"></div>
<div th:replace="fragment/popup/customPopup2 :: #customConfirm"></div>
<div th:replace="fragment/popup/emailResendPopup :: emailResendPopup"></div>
<div th:replace="fragment/popup/passwordInputPopup :: passwordInputPopup"></div>
</div>
@@ -0,0 +1,45 @@
<!-- views/fragment/popup/passwordInputPopup.html -->
<div th:fragment="passwordInputPopup" id="passwordInputPopup" style="display: none;">
<!-- Modal Backdrop -->
<div class="modal-backdrop" id="passwordModalBackdrop"></div>
<!-- Modal Wrapper -->
<div class="modal" id="passwordModal">
<div class="modal-dialog">
<!-- Modal Header -->
<div class="modal-header">
<h3 class="modal-title" id="passwordPopupTitle">비밀번호 입력</h3>
<button type="button" class="modal-close" id="passwordPopupCloseButton">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<!-- Modal Body -->
<div class="modal-body">
<p id="passwordPopupMessage" style="text-align: center; margin-bottom: 24px; color: #64748B;">
계속하려면 비밀번호를 입력해주세요.
</p>
<!-- Password Input Field -->
<div class="pop_input_group">
<input type="password"
id="passwordPopupInput"
class="pop_input_field"
placeholder="비밀번호를 입력하세요">
<div id="passwordPopupError" class="error-message"></div>
</div>
</div>
<!-- Modal Footer -->
<div class="modal-footer">
<button type="button" class="btn btn-secondary" id="passwordPopupCancelButton">취소</button>
<button type="button" class="btn btn-primary" id="passwordPopupConfirmButton">확인</button>
</div>
</div>
</div>
</div>
@@ -10,9 +10,8 @@
<body>
<!-- header -->
<header th:replace="fragment/header :: headerFragment"></header>
<th:block th:replace="fragment/header :: headerScript"></th:block>
<nav th:replace="fragment/header :: navFragment"></nav>
<th:block th:replace="fragment/kjbank/header_container :: headerFragment"></th:block>
<th:block th:replace="fragment/kjbank/header_container :: headerScript"></th:block>
<!-- csrf -->
<div>
@@ -10,9 +10,8 @@
<body>
<!-- header -->
<header th:replace="fragment/header :: headerFragment"></header>
<th:block th:replace="fragment/header :: headerScript"></th:block>
<nav th:replace="fragment/header :: navFragment"></nav>
<th:block th:replace="fragment/kjbank/header_container :: headerFragment"></th:block>
<th:block th:replace="fragment/kjbank/header_container :: headerScript"></th:block>
<div>
<section layout:fragment="contentFragment">
@@ -12,8 +12,10 @@
<div class="loading-overlay" style="display: none;">
<div class="loading-spinner"></div>
</div>
<th:block layout:fragment="contentFragment">
</th:block>
<div class="container">
<th:block layout:fragment="contentFragment">
</th:block>
</div>
<th:block layout:fragment="contentScript">
</th:block>