12 Commits

Author SHA1 Message Date
Rinjae 37be9a361a 로고 및 스타일 변경 반영:
- 로고 사이즈 수정 (114px → 144px) 및 레이아웃 조정
- 컬러 및 폰트 스타일 Figma 가이드 기준 업데이트
- 버튼/배경 스타일 효과 개선 (글래스, 알약 태그 등)
2026-07-01 09:54:50 +09:00
Rinjae a91b56ed11 - 법인 탈퇴 시 민감정보 삭제 로직 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- CSS 수정: 폰트 크기 제거 및 스타일 변경 적용
- 예상완료일 요일 계산 로직 추가
2026-06-30 15:38:40 +09:00
Rinjae 8d232734b1 IP 등록 UI 및 검증 로직 업데이트
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- `.ip-input-row`/`.btn-add-ip` 정렬 및 버튼 크기 수정
- Callback URL 관련 입력 및 검증 로직 주석 처리
- 화이트리스트 필수 입력 항목 검증 로직 추가
2026-06-30 10:54:22 +09:00
Rinjae 06c18e437b 회원가입 제출 필드 개선: 선택 입력 포함 모든 항목 전송 처리 2026-06-30 10:54:08 +09:00
Rinjae 97b9d2161a IP 입력 필드 스타일 개선: 정렬 및 버튼 크기 수정
- `.ip-input-row` 정렬 기준 추가 및 버튼 폭 고정
- `.btn-add-ip` 및 제거 버튼 스타일/반응형 조정
2026-06-30 10:54:00 +09:00
Rinjae 0c3b63a5b1 - UserSessionService 목 객체 추가 및 관련 의존성 주입
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 초대 조회 시 휴대폰 번호 형식 통일 (`01012345678` → `010-1234-5678`)
- AccountControllerTest 테스트 코드 수정 및 누락 보완
2026-06-30 09:45:08 +09:00
Rinjae 582a3e0f69 회원가입 필수 항목 검증 강화:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 입력/미충족 항목 구체적 수집 및 에러 메시지 개선
- 시나리오별/공통 항목 검증 로직 추가
2026-06-30 09:42:34 +09:00
Rinjae c1ae12e1ca 전화번호 정규화 유틸 추가 및 적용:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- PhoneNumberUtil 도입: 한국번호 정규화/숫자 추출 지원
- 유틸 적용: 모든 저장 및 조회에 정규형 강제
- 초대 매칭 오류 수정: 휴대폰 형식 차이 대응
2026-06-29 18:26:15 +09:00
Rinjae 77aea79363 콘솔 로그 설정 추가 및 레벨 제어 기능 구현
- JVM 파라미터 기반 CONSOLE_LOG_ENABLED, LOG_LEVEL 제어 추가
- Stage 프로파일 정리 및 콘솔 로그 필터 설정 적용
2026-06-29 18:24:37 +09:00
Rinjae aa5e769148 JTA 설정 비활성화 및 신규 회원가입 경로 추가
- `spring.jta.enabled` 값 false로 변경 (stage/dev/prod 공통)
- 법인회원 초대 경로(`/signup/decision_process`) 신규 추가
2026-06-29 18:24:26 +09:00
Rinjae ee8e9e3529 CycloneDX BOM 플러그인 추가 - build.gradle에 org.cyclonedx.bom 적용 2026-06-29 18:24:07 +09:00
Rinjae 82948a13ce CSRF 만료 세션 처리 및 정적 리소스 캐싱 제어 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- CSRF 만료 시 기존 세션 만료 안내로 리다이렉트 처리
- 정적 리소스 캐싱 토글 기능 추가 (개발 환경 즉시 반영 지원)
- 회원가입 페이지 및 세션 설정 스타일 수정
2026-06-26 15:05:19 +09:00
52 changed files with 1200 additions and 646 deletions
+1
View File
@@ -3,6 +3,7 @@ plugins {
id 'war'
id 'eclipse'
id 'idea'
id 'org.cyclonedx.bom' version '3.2.4'
id 'org.springframework.boot' version '2.7.18'
id 'io.spring.dependency-management' version '1.1.3'
}
+47
View File
@@ -8,6 +8,9 @@
"name": "eapim-portal",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"playwright": "^1.61.1"
},
"devDependencies": {
"sass": "^1.69.5"
}
@@ -349,6 +352,20 @@
"node": ">=8"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/immutable": {
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
@@ -403,6 +420,36 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
+4 -1
View File
@@ -13,5 +13,8 @@
"sass": "^1.69.5"
},
"author": "",
"license": "ISC"
"license": "ISC",
"dependencies": {
"playwright": "^1.61.1"
}
}
@@ -31,4 +31,21 @@ public class ApprovalDTO {
private LocalDateTime createdDate;
private LocalDateTime approvalDate;
private String expectEndDate; //예상완료일 (yyyyMMdd)
/** 예상완료일(yyyyMMdd)의 한글 요일. 값이 없거나 형식이 맞지 않으면 빈 문자열. (예: "토") */
public String getExpectEndDateDayOfWeek() {
if (expectEndDate == null || expectEndDate.length() < 8) {
return "";
}
try {
return java.time.LocalDate
.parse(expectEndDate.substring(0, 8), java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd"))
.getDayOfWeek()
.getDisplayName(java.time.format.TextStyle.SHORT, java.util.Locale.KOREAN);
} catch (Exception e) {
return "";
}
}
}
@@ -19,6 +19,10 @@ public interface UserSessionRepository extends JpaRepository<UserSession, String
@Query("UPDATE UserSession s SET s.forceLogout = 'Y' WHERE s.loginId = :loginId AND s.sessionId <> :currentSessionId")
int forceLogoutOtherSessions(@Param("loginId") String loginId, @Param("currentSessionId") String currentSessionId);
@Modifying
@Query("UPDATE UserSession s SET s.forceLogout = 'Y' WHERE s.loginId = :loginId")
int forceLogoutAllSessions(@Param("loginId") String loginId);
@Modifying
@Query("DELETE FROM UserSession s WHERE s.lastAccessTime < :cutoffTime")
int deleteExpiredSessions(@Param("cutoffTime") LocalDateTime cutoffTime);
@@ -74,6 +74,18 @@ public class UserSessionService {
}
}
/**
* 해당 사용자의 모든 세션에 강제 로그아웃 플래그 설정.
* 관리자의 권한 변경(위임/회수/소속제외)을 대상 사용자의 활성 세션에 반영하기 위해 사용한다.
*/
@Transactional
public void forceLogoutAllSessions(String loginId) {
int count = userSessionRepository.forceLogoutAllSessions(loginId);
if (count > 0) {
log.info("전체 강제 로그아웃 처리 - loginId: {}, 대상 세션 수: {}", loginId, count);
}
}
/**
* 현재 세션의 강제 로그아웃 여부 확인
*/
@@ -1,28 +1,18 @@
package com.eactive.apim.portal.apps.user.controller;
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
import com.eactive.apim.portal.apps.user.dto.PasswordChangeRequestDTO;
import com.eactive.apim.portal.apps.user.dto.PortalOrgRegistrationDTO;
import com.eactive.apim.portal.apps.user.dto.PortalUserDTO;
import com.eactive.apim.portal.apps.user.dto.UserAgreementDTO;
import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import com.eactive.apim.portal.apps.user.dto.*;
import com.eactive.apim.portal.apps.user.facade.OrgRegisterFacade;
import com.eactive.apim.portal.apps.user.facade.UserFacade;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.invitation.entity.UserInvitation;
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums.InvitationStatus;
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -33,14 +23,19 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Controller
@Secured("ROLE_ACCOUNT")
@RequiredArgsConstructor
@@ -53,6 +48,7 @@ public class AccountController {
private final AgreementsFacade agreementsFacade;
private final com.eactive.apim.portal.apps.user.facade.AuthFacade authFacade;
private final UserInvitationRepository userInvitationRepository;
private final UserSessionService userSessionService;
@PostMapping("/confirm_password")
@@ -105,6 +101,12 @@ public class AccountController {
session.removeAttribute("success");
session.removeAttribute("redirectUrl");
// 세션 무효화 전에 DB 세션 레코드를 정리한다.
// SecurityContextLogoutHandler 는 HTTP 세션만 invalidate 하고 UserSession DB 레코드는
// 남기므로(정상 로그아웃의 PortalLogoutSuccessHandler 경로를 우회), 정리하지 않으면
// 재로그인 시 check-duplicate 가 이 옛 세션을 활성으로 보고 "이미 접속중" 으로 오탐한다.
userSessionService.removeSession(session.getId());
new SecurityContextLogoutHandler().logout(request, response,
SecurityContextHolder.getContext().getAuthentication());
@@ -143,7 +145,7 @@ public class AccountController {
if (currentUser.getRoleCode() == RoleCode.ROLE_USER) {
java.util.Optional<UserInvitation> pendingInvitation =
userInvitationRepository.findFirstByInvitationMobileAndStatus(
currentUser.getMobileNumber(), InvitationStatus.PENDING);
PhoneNumberUtil.normalize(currentUser.getMobileNumber()), InvitationStatus.PENDING);
if (pendingInvitation.isPresent()) {
mav.addObject("hasPendingInvitation", true);
@@ -7,9 +7,11 @@ import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
import com.eactive.apim.portal.apps.user.facade.AuthFacade;
import com.eactive.apim.portal.apps.user.facade.UserRegisterFacade;
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
import com.eactive.apim.portal.apps.user.service.PortalUserService;
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
import com.eactive.apim.portal.common.util.EncryptionUtil;
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.config.PortalProperties;
import com.eactive.apim.portal.invitation.entity.UserInvitation;
@@ -63,6 +65,7 @@ public class UserRegisterController {
private final UserInvitationRepository userInvitationRepository;
private final EncryptionUtil encryptionUtil;
private final AgreementValidator agreementValidator;
private final PortalUserAuthService portalUserAuthService;
@GetMapping("/signup")
public String showSignupSelection() {
@@ -243,6 +246,11 @@ public class UserRegisterController {
String decodedToken = decodeInvitationToken(invitationToken);
session.setAttribute("decisionToken", decodedToken);
// 이미 로그인된 상태면 수락 화면으로 직행
if (SecurityUtil.isAuthenticated()) {
return "redirect:/signup/decision_process";
}
Optional<UserInvitation> invitation = userInvitationRepository.findByToken(decodedToken);
if (!invitation.isPresent() || invitation.get().getStatus() != UserInvitationEnums.InvitationStatus.PENDING) {
@@ -281,7 +289,7 @@ public class UserRegisterController {
public String showDecisionProcessPage(HttpSession session, Model model) {
session.removeAttribute("decisionToken");
String mobile = SecurityUtil.getPortalAuthenticatedUser().getMobileNumber();
String mobile = PhoneNumberUtil.normalize(SecurityUtil.getPortalAuthenticatedUser().getMobileNumber());
Optional<UserInvitation> invitation = userInvitationRepository.findFirstByInvitationMobileAndStatus(mobile, UserInvitationEnums.InvitationStatus.PENDING);
if (!invitation.isPresent()) {
@@ -305,6 +313,8 @@ public class UserRegisterController {
model.addAttribute("invitationCode", invitation.get().getToken());
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
model.addAttribute("agreementTitle", "법인 회원 전환을 위한 약관 동의");
model.addAttribute("registrationType", "corporate");
return "apps/register/userDecisionProcess";
@@ -323,7 +333,7 @@ public class UserRegisterController {
agreementValidator.validate(agreement, bindingResult);
Optional<UserInvitation> invitation = userInvitationRepository.findFirstByInvitationMobileAndStatus(SecurityUtil.getPortalAuthenticatedUser().getMobileNumber(), UserInvitationEnums.InvitationStatus.PENDING);
Optional<UserInvitation> invitation = userInvitationRepository.findFirstByInvitationMobileAndStatus(PhoneNumberUtil.normalize(SecurityUtil.getPortalAuthenticatedUser().getMobileNumber()), UserInvitationEnums.InvitationStatus.PENDING);
if (action.equalsIgnoreCase("accept") && bindingResult.hasErrors()) {
@@ -334,12 +344,19 @@ public class UserRegisterController {
model.addAttribute("orgName", org.get().getOrgName());
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
model.addAttribute("agreementTitle", "법인 회원 전환을 위한 약관 동의");
model.addAttribute("registrationType", "corporate");
return "apps/register/userDecisionProcess";
} else {
ValidationResponse response = userRegisterFacade.processInvitation(action, invitation.get());
// 수락 성공 시 재로그인 없이 본인 세션 권한 갱신 (ROLE_USER → ROLE_CORP_USER)
if ("accept".equalsIgnoreCase(action) && response.isValid()) {
portalUserAuthService.reloadCurrentAuthentication();
}
// 초대 처리 완료 후 세션에서 초대 관련 속성 제거
session.removeAttribute("pendingInvitation");
session.removeAttribute("pendingInvitationToken");
@@ -7,6 +7,7 @@ import com.eactive.apim.portal.apps.user.mapper.PortalUserMapper;
import com.eactive.apim.portal.common.exception.NotFoundException;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.DurationParser;
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.util.StringMaskingUtil;
import com.eactive.apim.portal.common.validator.CellPhoneValidator;
@@ -19,6 +20,8 @@ import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.UserStatus;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import com.eactive.apim.portal.template.service.MessageHandlerService;
@@ -54,6 +57,8 @@ public class UserManFacade {
private final UserLogRepository userLogRepository;
private final PortalPropertyService portalPropertyService;
private final CellPhoneValidator cellPhoneValidator;
private final UserSessionService userSessionService;
private final PortalUserAuthService portalUserAuthService;
public Page<PortalUserDTO> getUsers(PortalOrgDTO userOrg, Pageable pageable) {
return portalUserRepository
@@ -178,16 +183,7 @@ public class UserManFacade {
* 010-XXXX-XXXX(11자리) / 010-XXX-XXXX(10자리). 형식 불명은 원본 반환(검증에서 차단됨).
*/
private String normalizeMobile(String raw) {
if (raw == null) {
return null;
}
String digits = raw.replaceAll("[^0-9]", "");
if (digits.length() == 11) {
return digits.substring(0, 3) + "-" + digits.substring(3, 7) + "-" + digits.substring(7);
} else if (digits.length() == 10) {
return digits.substring(0, 3) + "-" + digits.substring(3, 6) + "-" + digits.substring(6);
}
return raw;
return PhoneNumberUtil.normalize(raw);
}
public String createInvitation(PortalUser sender, String mobile) {
@@ -253,6 +249,10 @@ public class UserManFacade {
portalUserRepository.save(targetUser);
portalUserRepository.save(currentUser);
// 권한 변경 즉시 반영: 대상(타 세션)은 강제 로그아웃, 본인(현재 세션)은 in-place 재인증
userSessionService.forceLogoutAllSessions(targetUser.getLoginId());
portalUserAuthService.reloadCurrentAuthentication();
MessageRecipient recipient = new MessageRecipient();
recipient.setUserId(targetUser.getEmailAddr());
recipient.setPhone(targetUser.getMobileNumber());
@@ -284,6 +284,9 @@ public class UserManFacade {
targetUser.setRoleCode(RoleCode.ROLE_CORP_USER);
portalUserRepository.save(targetUser);
// 권한 회수를 대상 사용자의 활성 세션에 반영 (강제 로그아웃 → 재로그인 시 새 권한)
userSessionService.forceLogoutAllSessions(targetUser.getLoginId());
}
/**
@@ -305,6 +308,9 @@ public class UserManFacade {
targetUser.setPortalOrg(null);
targetUser.setRoleCode(RoleCode.ROLE_USER);
portalUserRepository.save(targetUser);
// 소속 제외를 대상 사용자의 활성 세션에 반영 (강제 로그아웃 → 재로그인 시 새 권한)
userSessionService.forceLogoutAllSessions(targetUser.getLoginId());
}
public void cancelInvitation(PortalAuthenticatedUser user, String invitationId, boolean notifyConsent) {
@@ -263,7 +263,7 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
ValidationResponse response = new ValidationResponse();
response.setValid(true);
response.setMessage("초대가 성공적으로 수락되었습니다. 로그아웃 후 다시 로그인 해 주세요.");
response.setMessage("초대가 성공적으로 수락되었습니다. 법인회원으로 전환되었습니다.");
response.setShowPopup(true);
response.setPopupType("success");
return response;
@@ -6,6 +6,7 @@ import com.eactive.apim.portal.common.exception.SystemException;
import com.eactive.apim.portal.common.exception.UserNotFoundException;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.EncryptionUtil;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.util.StringMaskingUtil;
import com.eactive.apim.portal.config.PortalProperties;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
@@ -30,7 +31,9 @@ import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import lombok.RequiredArgsConstructor;
import org.apache.xerces.impl.dv.util.Base64;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
@@ -58,21 +61,46 @@ public class PortalUserAuthService implements UserDetailsService {
// 이메일 소문자 변환 적용 (대소문자 구분 없이 로그인 가능하도록)
String normalizedUsername = username != null ? username.toLowerCase() : null;
PortalUser portalUser = findByEmailAddr(normalizedUsername);
RoleCode userRole = portalUser.getRoleCode() == null ? RoleCode.ROLE_USER : portalUser.getRoleCode();
List<String> roles = portalProperties.getPortalSecurity().get(userRole);
PortalAuthenticatedUser authenticatedUser = portalUserMapper.portalUserToAuthenticatedUser(portalUser);
authenticatedUser.getAuthorities().add(new SimpleGrantedAuthority(userRole.name()));
roles.forEach(role -> authenticatedUser.getAuthorities().add(new SimpleGrantedAuthority(role)));
return authenticatedUser;
return buildAuthenticatedUser(portalUser);
} catch (UserNotFoundException e) {
throw new UsernameNotFoundException("입력하신 사용자 정보가 올바르지 않습니다. 다시 확인해 주세요.");
}
}
/**
* DB의 PortalUser 로부터 권한(roleCode + 역할 계층) 채운 PortalAuthenticatedUser 생성한다.
* 로그인과 세션 재로드가 동일한 권한 구성을 사용하도록 공통화한다.
*/
public PortalAuthenticatedUser buildAuthenticatedUser(PortalUser portalUser) {
RoleCode userRole = portalUser.getRoleCode() == null ? RoleCode.ROLE_USER : portalUser.getRoleCode();
List<String> roles = portalProperties.getPortalSecurity().get(userRole);
PortalAuthenticatedUser authenticatedUser = portalUserMapper.portalUserToAuthenticatedUser(portalUser);
authenticatedUser.getAuthorities().add(new SimpleGrantedAuthority(userRole.name()));
roles.forEach(role -> authenticatedUser.getAuthorities().add(new SimpleGrantedAuthority(role)));
return authenticatedUser;
}
/**
* 현재 로그인한 사용자의 권한을 DB에서 다시 읽어 SecurityContext Authentication 교체한다.
* 재로그인 없이 본인 세션에 역할 변경(초대 수락, 관리자 위임으로 인한 본인 강등 ) 즉시 반영한다.
*/
public void reloadCurrentAuthentication() {
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
if (current == null) {
return;
}
PortalUser fresh = portalUserRepository.findByLoginId(current.getLoginId())
.orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다."));
PortalAuthenticatedUser refreshed = buildAuthenticatedUser(fresh);
UsernamePasswordAuthenticationToken newAuth =
new UsernamePasswordAuthenticationToken(refreshed, null, refreshed.getAuthorities());
newAuth.setDetails(refreshed);
SecurityContextHolder.getContext().setAuthentication(newAuth);
}
public List<PortalUserDTO> findAllUsersByNameAndMobile(String userName, String mobileNumber) {
try {
if (mobileNumber == null || !mobileNumber.matches("^\\d{2,3}-\\d{3,4}-\\d{4}$")) {
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.apps.user.service;
import com.eactive.apim.portal.apps.user.dto.PortalUserRegistrationDTO;
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
@@ -83,7 +84,7 @@ public class PortalUserService {
if (mobileNumber == null || mobileNumber.trim().isEmpty()) {
return false;
}
return portalUserRepository.existsByMobileNumber(mobileNumber.trim());
return portalUserRepository.existsByMobileNumber(PhoneNumberUtil.normalize(mobileNumber));
}
/**
@@ -4,6 +4,7 @@ import com.eactive.apim.portal.apps.session.service.UserSessionService;
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
import com.eactive.apim.portal.apps.user.service.PortalUserLogService;
import com.eactive.apim.portal.common.util.HttpRequestUtil;
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
import com.eactive.apim.portal.common.util.StringRepeatUtil;
import com.eactive.apim.portal.invitation.entity.UserInvitation;
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums.InvitationStatus;
@@ -95,9 +96,10 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
// 초대 코드 확인 - ROLE_USER만 확인 (세션에 저장하여 메인 페이지에서 팝업으로 표시)
if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_USER) {
// 휴대폰 형식(하이픈 유무) 달라도 초대와 매칭되도록 정규화 조회
Optional<UserInvitation> pendingInvitation =
userInvitationRepository.findFirstByInvitationMobileAndStatus(
user.getMobileNumber(), InvitationStatus.PENDING);
PhoneNumberUtil.normalize(user.getMobileNumber()), InvitationStatus.PENDING);
if (pendingInvitation.isPresent()) {
UserInvitation invitation = pendingInvitation.get();
@@ -12,7 +12,10 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.access.AccessDeniedHandlerImpl;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.csrf.CsrfException;
import org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository;
import org.springframework.security.web.session.HttpSessionEventPublisher;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@@ -107,6 +110,11 @@ public class PortalConfigSecurity {
.ignoringRequestMatchers(new AntPathRequestMatcher("/api/session/check-duplicate"))
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/migration/**"))
)
// 로그인 페이지에 오래 머물러 세션(=CSRF 토큰 저장소) 타임아웃되면
// 로그인 제출 CsrfFilter가 AnonymousAuthenticationFilter보다 먼저 예외를 던져
// 익명으로 인식되지 못하고 기본 403("권한없음")으로 떨어진다.
// CSRF 만료는 권한 문제가 아니라 세션 만료이므로 기존 안내(/login?expired=true) 보낸다.
.exceptionHandling(ex -> ex.accessDeniedHandler(csrfAwareAccessDeniedHandler()))
// 중복로그인/유휴 만료는 DB 기반 SessionValidationFilter가 처리 (maximumSessions 미사용)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
@@ -121,6 +129,23 @@ public class PortalConfigSecurity {
}
}
/**
* CSRF 토큰 누락/불일치는 "권한없음" 아니라 (대개) 세션 만료다.
* 로그인 페이지에 오래 머물러 세션 CSRF 토큰이 사라진 경우가 대표적이므로
* 기존 세션 만료 안내(login.html의 {@code param.expired}) 재사용해 오해를 막는다.
* 진짜 권한 위반(@Secured ) 기본 핸들러에 위임해 403을 유지한다.
*/
private AccessDeniedHandler csrfAwareAccessDeniedHandler() {
AccessDeniedHandlerImpl delegate = new AccessDeniedHandlerImpl();
return (request, response, accessDeniedException) -> {
if (accessDeniedException instanceof CsrfException) {
response.sendRedirect(request.getContextPath() + "/login?expired=true");
return;
}
delegate.handle(request, response, accessDeniedException);
};
}
@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
return new HttpSessionEventPublisher();
@@ -45,6 +45,12 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
@Value("${app.resource-versioning.enabled:true}")
private boolean resourceVersioningEnabled;
// 정적자원 서버측 캐싱(ResourceChain 캐시) 토글(application.yml: app.resource-caching.enabled).
// prod 값을 무시하고 항상 ON 으로 동작한다(isResourceCachingEnabled 참고).
// 개발환경에서 OFF sass/JS 변경이 서버 재시작 없이 즉시 반영된다.
@Value("${app.resource-caching.enabled:false}")
private boolean resourceCachingEnabled;
public PortalConfigWebDispatcherServlet(Environment environment) {
this.environment = environment;
}
@@ -97,10 +103,15 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
* {@code @{/css/main.css}} 링크는 {@link #resourceUrlEncodingFilter()} 해시 URL 치환한다.</p>
*/
private void addStaticResourceHandler(ResourceHandlerRegistry registry, String pattern, String... locations) {
// 캐싱 ON(prod) 이면 브라우저에 1일 캐시를, OFF(dev/local) no-store 내려보낸다.
// no-store 브라우저가 아예 저장하지 않으므로 강제 새로고침 없이 sass/JS 변경이 바로 보인다.
CacheControl cacheControl = isResourceCachingEnabled()
? CacheControl.maxAge(1, TimeUnit.DAYS)
: CacheControl.noStore();
ResourceChainRegistration chain = registry.addResourceHandler(pattern)
.addResourceLocations(locations)
.setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS))
.resourceChain(true);
.setCacheControl(cacheControl)
.resourceChain(isResourceCachingEnabled());
if (isResourceVersioningEnabled()) {
chain.addResolver(new VersionResourceResolver().addContentVersionStrategy("/**"));
}
@@ -118,6 +129,20 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
return resourceVersioningEnabled;
}
/**
* 정적자원 서버측 캐싱(ResourceChain CachingResourceResolver) 활성 여부.
* prod 프로파일은 토글과 무관하게 항상 ON, 프로파일은 {@code app.resource-caching.enabled} 값을 따른다.
*
* <p>OFF 요청마다 리소스를 재해석하므로, sass/JS 변경이 서버 재시작 없이 즉시 반영된다.
* ({@code spring.web.resources.cache} 브라우저 캐시 헤더만 제어할 서버측 캐시와는 무관하다.)</p>
*/
private boolean isResourceCachingEnabled() {
if (environment.acceptsProfiles(Profiles.of("prod"))) {
return true;
}
return resourceCachingEnabled;
}
@Bean
public MappingJackson2JsonView jsonView() {
+1 -1
View File
@@ -1,6 +1,6 @@
spring:
jta:
enabled: true
enabled: false
config:
activate:
on-profile: dev
+8 -2
View File
@@ -1,6 +1,6 @@
spring:
jta:
enabled: true
enabled: false
config:
activate:
on-profile: prod
@@ -29,4 +29,10 @@ spring:
cache: false
portal:
test-auth-notice-enabled: false # prod 환경: UI 안내 미표시
test-auth-notice-enabled: false # prod 환경: UI 안내 미표시
app:
resource-versioning:
enabled: true
resource-caching:
enabled: true
+1 -1
View File
@@ -1,6 +1,6 @@
spring:
jta:
enabled: true
enabled: false
web:
resources:
cache:
+15 -4
View File
@@ -2,7 +2,9 @@ server:
servlet:
context-path: /
session:
timeout: 15m
timeout: 10m
cookie:
name: PORTAL_JSESSIONID
encoding:
charset: UTF-8
port: '39130'
@@ -39,10 +41,11 @@ spring:
# 아래 app.resource-versioning.enabled 토글을 읽어 직접 수행한다.
jta:
enabled: true
enabled: false
atomikos:
properties:
log-base-dir: /logs/atomikos
# log-base-dir: /logs/eapim/${inst.Name:devSvr11}/atomikos
log-base-dir: /dev/null
log-base-name: adp_tx
thymeleaf:
@@ -59,7 +62,12 @@ spring:
# prod 는 PortalConfigWebDispatcherServlet 에서 항상 ON 으로 고정되어 이 값을 무시함.
app:
resource-versioning:
enabled: true
enabled: false
# 정적자원 서버측 캐싱(ResourceChain 캐시) 토글 (prod 제외 전 프로파일에 적용).
# false 면 sass/JS 변경이 서버 재시작 없이 즉시 반영됨(매 요청 재해석).
# prod 는 PortalConfigWebDispatcherServlet 에서 항상 ON 으로 고정되어 이 값을 무시함.
resource-caching:
enabled: false
security:
basic:
@@ -250,6 +258,9 @@ page:
verification_email:
name: "이메일 인증"
path: "/signup/verification-email"
decision:
name: "법인회원 초대"
path: "/signup/decision_process"
agreements:
name: 약관
path: "#"
+16 -16
View File
@@ -5,13 +5,20 @@
<property name="LOG_PATH" value="${profileLogPath}/${inst.Name:-devSvr00}"/>
<!-- <springProfile name="gf63">-->
<!-- <property name="LOG_PATH" value="d:/kjb-logs/${inst.Name:-devSvr00}"/>-->
<!-- </springProfile>-->
<!-- <property name="CONSOLE_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} %magenta([%thread]) %highlight([%-3level]) %logger{5} - %msg %n" />-->
<property name="CONSOLE_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} %magenta([%thread]) %highlight([%-3level]) %logger - %msg %n" />
<!-- 콘솔 로그 JVM 파라미터 제어 -->
<!-- 사용: -DCONSOLE_LOG_ENABLED=true -DCONSOLE_LOG_LEVEL=DEBUG -->
<!-- 기본값: 비활성(콘솔 OFF), 활성 시 레벨 INFO -->
<property name="CONSOLE_LOG_ENABLED" value="${CONSOLE_LOG_ENABLED:-false}"/>
<property name="CONSOLE_LOG_LEVEL" value="${CONSOLE_LOG_LEVEL:-INFO}"/>
<!-- enabled=true → 지정 레벨, false → OFF (대소문자 모두 대응) -->
<property name="__consoleLevel.true" value="${CONSOLE_LOG_LEVEL}"/>
<property name="__consoleLevel.TRUE" value="${CONSOLE_LOG_LEVEL}"/>
<property name="__consoleLevel.false" value="OFF"/>
<property name="__consoleLevel.FALSE" value="OFF"/>
<property name="CONSOLE_EFFECTIVE_LEVEL" value="${__consoleLevel.${CONSOLE_LOG_ENABLED}}"/>
<appender name="HIBERNATE_APPENDER" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/hibernate.log</file>
@@ -50,6 +57,9 @@
</appender>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>${CONSOLE_EFFECTIVE_LEVEL}</level>
</filter>
<encoder><Pattern>${CONSOLE_PATTERN}</Pattern></encoder>
</appender>
@@ -61,6 +71,7 @@
<root level="INFO">
<appender-ref ref="ROLLING"/>
<appender-ref ref="CONSOLE"/>
</root>
<springProfile name="dev">
@@ -69,15 +80,4 @@
<appender-ref ref="CONSOLE"/>
</root>
</springProfile>
<springProfile name="stage">
<root level="INFO">
<appender-ref ref="ROLLING"/>
</root>
</springProfile>
<springProfile name="stage,dev">
</springProfile>
</configuration>
+342 -251
View File
@@ -748,12 +748,13 @@ body.design-survey-active .global-header {
position: relative;
background-color: #ffffff;
z-index: 350;
height: 112px;
height: 110px;
transition: all 0.3s ease;
border-bottom: 1px solid #dddddd;
}
.global-header.index-header {
background-color: #E5F8FF;
background-color: #FFFFFF;
border-bottom: none;
}
.global-header .container {
height: 100%;
@@ -809,10 +810,10 @@ body.design-survey-active .global-header {
}
.logo-text {
font-size: 22px;
font-weight: 600;
font-size: 23px;
font-weight: 500;
line-height: 32px;
color: var(--text-dark);
color: #000000;
text-decoration: none;
display: inline-flex;
align-items: center;
@@ -1463,8 +1464,8 @@ body.design-survey-active .global-header {
align-items: center;
gap: 6px;
text-decoration: none;
color: var(--text-dark);
font-weight: 500;
color: #212529;
font-weight: 700;
font-size: 20px;
padding: 8px 16px;
border-radius: 8px;
@@ -1655,7 +1656,7 @@ body.design-survey-active .global-header {
gap: 16px;
}
.footer-left .footer-logo {
width: 114px;
width: 144px;
height: auto;
object-fit: contain;
}
@@ -1667,7 +1668,7 @@ body.design-survey-active .global-header {
.footer-left .footer-links .footer-link {
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 16px;
font-weight: 400;
font-weight: 700;
color: #5F666C;
text-decoration: none;
transition: color 0.3s ease;
@@ -1677,7 +1678,7 @@ body.design-survey-active .global-header {
text-decoration: underline;
}
.footer-left .footer-links .footer-separator {
color: #D1D5DB;
color: #5F666C;
font-size: 16px;
user-select: none;
}
@@ -1685,7 +1686,7 @@ body.design-survey-active .global-header {
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
font-weight: 400;
color: #9CA3AF;
color: #5F666C;
margin: 0;
line-height: 1.5;
}
@@ -1711,8 +1712,8 @@ body.design-survey-active .global-header {
font-weight: 400;
color: #5F666C;
background: #FFFFFF;
border: 1px solid #D1D5DB;
border-radius: 6px;
border: 1px solid #5F666C;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s ease;
appearance: none;
@@ -1736,15 +1737,15 @@ body.design-survey-active .global-header {
}
.footer-right .footer-contact {
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 18px;
font-size: 30px;
font-weight: 700;
color: #1F2937;
color: #5F666C;
margin: 0;
line-height: 1.2;
}
@media (max-width: 1024px) {
.footer-right .footer-contact {
font-size: 16px;
font-size: 22px;
}
}
@@ -5073,20 +5074,20 @@ select.form-control {
transition: all 0.3s ease;
}
.login-btn-box {
height: 32px;
height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 6px 16px;
color: #212529;
gap: 4px;
padding: 6px 16px 6px 12px;
color: #0046FF;
text-decoration: none;
border: none;
border-radius: 6px;
border: 1px solid #0046FF;
border-radius: 20px;
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-weight: 600;
font-size: 16px;
background: transparent;
font-weight: 400;
font-size: 19px;
background: #F2F5F7;
transition: all 0.3s ease;
}
.login-btn-box .user-icon {
@@ -5094,6 +5095,9 @@ select.form-control {
height: 18px;
flex-shrink: 0;
}
.login-btn-box:hover {
background: #E9EDF2;
}
.user-profile-dropdown {
position: relative;
@@ -6973,6 +6977,26 @@ button.djb-comment-submit:disabled {
font-size: 13px;
}
.landing-top {
position: relative;
background-color: #D4F0FC;
background-image: url("/img/bg_landing_top.png");
background-repeat: no-repeat;
background-size: 100% 100%;
background-position: top center;
}
.landing-top .hero-carousel-section,
.landing-top .hero-carousel-container,
.landing-top .hero-slide,
.landing-top .api-search-section,
.landing-top .api-showcase {
background: transparent !important;
}
.landing-top .showcase-background,
.landing-top .search-background {
display: none !important;
}
.hero-carousel-section {
position: relative;
width: 100%;
@@ -6984,7 +7008,7 @@ button.djb-comment-submit:disabled {
width: 100%;
height: 580px;
overflow: hidden;
background-color: #E9F9FF;
background: linear-gradient(180deg, #BAF5FA 0%, #76D4FC 100%);
}
@media (max-width: 1024px) {
.hero-carousel-container {
@@ -7027,13 +7051,13 @@ button.djb-comment-submit:disabled {
visibility: visible;
}
.hero-slide[data-slide="0"] {
background: #E9F9FF;
background: transparent;
}
.hero-slide[data-slide="1"] {
background: #E9F9FF;
background: transparent;
}
.hero-slide[data-slide="2"] {
background: #E9F9FF;
background: transparent;
}
.hero-slide-content {
@@ -7142,7 +7166,7 @@ button.djb-comment-submit:disabled {
width: 178px;
height: 60px;
padding: 10px;
background: #0049B4;
background: #2A69DE;
color: #FFFFFF;
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 20px;
@@ -7152,9 +7176,9 @@ button.djb-comment-submit:disabled {
transition: all 0.3s ease;
}
.btn-hero-signup:hover {
background: rgb(0, 65.7, 162);
background: rgb(31.8731707317, 92.7219512195, 205.7268292683);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 73, 180, 0.3);
box-shadow: 0 4px 12px rgba(42, 105, 222, 0.3);
}
.btn-hero-signup:active {
transform: translateY(0);
@@ -7369,10 +7393,14 @@ button.djb-comment-submit:disabled {
.api-search-section {
position: relative;
padding: 0;
overflow: hidden;
padding: 56px 20px 64px;
overflow: visible;
background: #e9f9ff;
height: 314px;
}
@media (max-width: 768px) {
.api-search-section {
padding: 28px 16px 32px;
}
}
.api-search-section .search-background {
position: absolute;
@@ -7383,70 +7411,52 @@ button.djb-comment-submit:disabled {
background-repeat: no-repeat;
pointer-events: none;
}
.api-search-section .search-content-wrapper {
.api-search-section .search-card {
position: relative;
z-index: 1;
max-width: 1308px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
gap: 40px;
z-index: 1;
gap: 56px;
padding: 40px 56px;
border-radius: 30px;
background: rgba(255, 255, 255, 0.45);
border: 1px solid #FFFFFF;
box-shadow: 0 10px 24px rgba(141, 155, 185, 0.25);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
@media (max-width: 1024px) {
.api-search-section .search-content-wrapper {
.api-search-section .search-card {
flex-direction: column;
align-items: flex-start;
gap: 24px;
padding-top: 40px;
padding: 32px;
}
}
@media (max-width: 768px) {
.api-search-section .search-content-wrapper {
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
padding-top: 20px;
justify-content: center;
}
}
.api-search-section .search-character {
width: 154px;
height: 148px;
flex-shrink: 0;
}
.api-search-section .search-character img {
width: 100%;
height: 100%;
object-fit: contain;
}
@media (max-width: 1024px) {
.api-search-section .search-character {
width: 120px;
height: 115px;
}
}
@media (max-width: 768px) {
.api-search-section .search-character {
width: 68px;
height: 65px;
.api-search-section .search-card {
gap: 16px;
padding: 20px;
border-radius: 20px;
}
}
.api-search-section .search-text-content {
text-align: left;
flex-shrink: 0;
}
@media (max-width: 1024px) {
.api-search-section .search-text-content {
text-align: center;
}
}
@media (max-width: 768px) {
.api-search-section .search-text-content {
text-align: left;
width: 100%;
}
}
.api-search-section .search-title {
font-size: 36px;
line-height: 1.3;
font-weight: 500;
line-height: 1.25;
color: #000000;
margin: 0;
white-space: nowrap;
}
@media (max-width: 1024px) {
.api-search-section .search-title {
@@ -7456,31 +7466,93 @@ button.djb-comment-submit:disabled {
@media (max-width: 768px) {
.api-search-section .search-title {
font-size: 16px;
line-height: 1.4;
line-height: 1.3;
color: #212529;
white-space: normal;
}
}
.api-search-section .search-main {
flex: 1;
min-width: 0;
width: 100%;
display: flex;
flex-direction: column;
gap: 18px;
}
@media (max-width: 768px) {
.api-search-section .search-main {
gap: 12px;
}
}
.api-search-section .hashtag-section {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
@media (max-width: 768px) {
.api-search-section .hashtag-section {
gap: 8px;
}
}
.api-search-section .hashtag-label {
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 16px;
font-weight: 700;
color: #000000;
white-space: nowrap;
}
@media (max-width: 768px) {
.api-search-section .hashtag-label {
font-size: 12px;
color: #212529;
}
}
.api-search-section .search-input-wrapper {
position: relative;
.api-search-section .hashtag-list {
display: flex;
justify-content: center;
padding: 0px 0 30px;
z-index: 1;
}
@media (max-width: 1024px) {
.api-search-section .search-input-wrapper {
padding: 30px 20px 20px;
}
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
@media (max-width: 768px) {
.api-search-section .search-input-wrapper {
justify-content: flex-start;
padding: 0px 28px 12px 27px;
.api-search-section .hashtag-list {
gap: 6px;
}
}
.api-search-section .hashtag-link {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 16px;
background: #4546CE;
color: #FFFFFF;
border-radius: 999px;
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 13px;
font-weight: 700;
text-decoration: none;
white-space: nowrap;
transition: background 0.2s ease, transform 0.2s ease;
}
.api-search-section .hashtag-link:hover {
background: rgb(52.7531914894, 53.829787234, 200.2468085106);
transform: translateY(-1px);
text-decoration: none;
}
@media (max-width: 768px) {
.api-search-section .hashtag-link {
padding: 4px 10px;
font-size: 11px;
}
}
.api-search-section .hashtag-separator {
display: none;
}
.api-search-section .search-input-wrapper {
width: 100%;
}
.api-search-section .search-form {
width: 100%;
max-width: 816px;
}
.api-search-section .search-box {
position: relative;
@@ -7495,16 +7567,16 @@ button.djb-comment-submit:disabled {
}
@media (max-width: 1024px) {
.api-search-section .search-box {
height: 60px;
border: 4px solid #0049B4;
height: 64px;
border-width: 4px;
padding: 0 16px;
}
}
@media (max-width: 768px) {
.api-search-section .search-box {
height: 36px;
border: 3px solid #0049B4;
border-radius: 18px;
height: 44px;
border-width: 3px;
border-radius: 22px;
padding: 0 12px;
}
}
@@ -7519,7 +7591,7 @@ button.djb-comment-submit:disabled {
font-size: 17px;
font-weight: 500;
color: #000000;
padding: 0 20px;
padding: 0 12px;
}
.api-search-section .search-input::placeholder {
color: #B3B3B3;
@@ -7528,18 +7600,14 @@ button.djb-comment-submit:disabled {
@media (max-width: 1024px) {
.api-search-section .search-input {
font-size: 15px;
padding: 0 12px;
}
}
@media (max-width: 768px) {
.api-search-section .search-input {
font-size: 11px;
font-weight: 500;
padding: 0 8px;
font-size: 12px;
}
.api-search-section .search-input::placeholder {
color: #8C959F;
font-weight: 500;
}
}
.api-search-section .search-icon-button {
@@ -7573,116 +7641,14 @@ button.djb-comment-submit:disabled {
}
@media (max-width: 768px) {
.api-search-section .search-icon-button svg {
width: 16px;
height: 16px;
width: 18px;
height: 18px;
}
}
@media (max-width: 768px) {
.api-search-section .search-icon-button {
width: 20px;
height: 20px;
}
}
.api-search-section .hashtag-section {
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 0 20px 0px;
z-index: 1;
}
@media (max-width: 1024px) {
.api-search-section .hashtag-section {
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 20px 30px;
}
}
@media (max-width: 768px) {
.api-search-section .hashtag-section {
flex-direction: row;
flex-wrap: wrap;
gap: 12px;
padding: 0 20px 20px;
justify-content: center;
}
}
.api-search-section .hashtag-label {
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 16px;
font-weight: 700;
color: #000000;
white-space: nowrap;
}
@media (max-width: 768px) {
.api-search-section .hashtag-label {
font-size: 12px;
color: #212529;
}
}
.api-search-section .hashtag-list {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
@media (max-width: 1024px) {
.api-search-section .hashtag-list {
justify-content: center;
gap: 10px;
}
}
@media (max-width: 768px) {
.api-search-section .hashtag-list {
gap: 8px;
justify-content: left;
}
}
.api-search-section .hashtag-link {
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 16px;
font-weight: 400;
color: #000000;
text-decoration: none;
white-space: nowrap;
transition: color 0.3s ease;
}
.api-search-section .hashtag-link:hover {
color: #0049B4;
text-decoration: underline;
}
@media (max-width: 1024px) {
.api-search-section .hashtag-link {
font-size: 14px;
}
}
@media (max-width: 768px) {
.api-search-section .hashtag-link {
font-size: 12px;
color: #212529;
}
}
.api-search-section .hashtag-separator {
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 16px;
color: #000000;
user-select: none;
}
@media (max-width: 1024px) {
.api-search-section .hashtag-separator {
font-size: 14px;
}
}
@media (max-width: 768px) {
.api-search-section .hashtag-separator {
font-size: 10px;
color: #212529;
display: flex;
align-items: center;
height: 10px;
width: 24px;
height: 24px;
}
}
@@ -7786,7 +7752,7 @@ button.djb-comment-submit:disabled {
font-size: 15px;
font-weight: 500;
color: #FFFFFF;
background: #008ae2;
background: #2A69DE;
text-decoration: none;
border-radius: 10px;
transition: all 0.3s ease;
@@ -7832,7 +7798,7 @@ button.djb-comment-submit:disabled {
}
}
.api-showcase .btn-all-apis:hover {
background: rgb(0, 124.2, 203.4);
background: rgb(31.8731707317, 92.7219512195, 205.7268292683);
}
.api-showcase .btn-all-apis:hover svg {
transform: translateX(4px);
@@ -7867,14 +7833,41 @@ button.djb-comment-submit:disabled {
flex-direction: column;
align-items: flex-start;
position: relative;
transition: all 0.3s ease;
overflow: hidden;
transition: transform 0.3s ease, box-shadow 0.3s ease, border-color 0.3s ease;
gap: 0px;
cursor: pointer;
}
.api-showcase .api-card::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(180deg, #50CAFF 0%, #2088FF 100%);
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
z-index: 0;
}
.api-showcase .api-card > * {
position: relative;
z-index: 1;
}
.api-showcase .api-card .card-title,
.api-showcase .api-card .card-description {
transition: color 0.3s ease;
}
.api-showcase .api-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
border-color: #008ae2;
border-color: transparent;
box-shadow: 0 10px 28px rgba(0, 14, 27, 0.22);
}
.api-showcase .api-card:hover::before {
opacity: 1;
}
.api-showcase .api-card:hover .card-title,
.api-showcase .api-card:hover .card-description {
color: #FFFFFF;
}
@media (max-width: 1024px) {
.api-showcase .api-card {
@@ -7964,12 +7957,26 @@ button.djb-comment-submit:disabled {
}
.info-section .info-background {
position: absolute;
inset: 0;
background-image: url("/img/bg_api_intro.svg");
background-size: cover;
background-position: center;
top: 0;
bottom: 0;
left: 0;
width: 54%;
background-image: url("/img/bg_diff_card.png");
background-size: 100% 100%;
background-repeat: no-repeat;
background-position: left top;
pointer-events: none;
}
@media (max-width: 1024px) {
.info-section .info-background {
width: 100%;
}
}
@media (max-width: 768px) {
.info-section .info-background {
display: none;
}
}
.info-section .container {
position: relative;
z-index: 1;
@@ -8022,7 +8029,7 @@ button.djb-comment-submit:disabled {
.info-section .info-title {
font-size: 28px;
line-height: 1.4;
color: #000000;
color: #FFFFFF;
margin: 0 0 24px 0;
}
@media (max-width: 1024px) {
@@ -8044,7 +8051,7 @@ button.djb-comment-submit:disabled {
}
.info-section .info-title .title-highlight {
font-weight: 700;
color: #0049B4;
color: #0FDBFF;
font-size: 36px;
}
@media (max-width: 768px) {
@@ -8060,7 +8067,7 @@ button.djb-comment-submit:disabled {
font-size: 20px;
font-weight: 400;
line-height: 1.6;
color: #000000;
color: #FFFFFF;
margin: 0 0 48px 0;
}
@media (max-width: 1024px) {
@@ -8098,17 +8105,17 @@ button.djb-comment-submit:disabled {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 18px 52px;
padding: 0 28px;
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 24px;
font-size: 18px;
font-weight: 700;
line-height: 56px;
border-radius: 20px;
line-height: 1.2;
border-radius: 14px;
text-decoration: none;
white-space: nowrap;
transition: all 0.3s ease;
min-width: 310px;
height: 128px;
min-width: 0;
height: 60px;
}
@media (max-width: 768px) {
.info-section .action-btn {
@@ -8137,22 +8144,24 @@ button.djb-comment-submit:disabled {
transform: translateY(0);
}
.info-section .action-btn-primary {
background: #0049B4;
color: #FFFFFF;
background: #D5EFFF;
color: #211894;
}
.info-section .action-btn-primary:hover {
background: rgb(0, 65.7, 162);
background: rgb(175.56, 224.7371428571, 255);
}
.info-section .action-btn-secondary {
background: #00ACDD;
background: #0046FF;
color: #FFFFFF;
border: none;
}
.info-section .action-btn-secondary:hover {
background: rgb(0, 154.8, 198.9);
background: #FFFFFF;
color: #0046FF;
}
.info-section .info-image {
flex: 1;
max-width: 560px;
flex: 0 1 460px;
max-width: 460px;
display: flex;
align-items: center;
justify-content: center;
@@ -8199,6 +8208,7 @@ button.djb-comment-submit:disabled {
}
}
.support-center .support-background {
display: none;
position: absolute;
top: 50%;
left: 50%;
@@ -8547,13 +8557,15 @@ button.djb-comment-submit:disabled {
}
}
.support-center .support-card--faq {
background: #DAF0FF;
background: #CAEAFF;
border: 1px solid #97D5FF;
}
.support-center .support-card--faq .card-content h3 {
color: #212529;
}
.support-center .support-card--qna {
background: #D9F6F8;
background: #C7F6F9;
border: 1px solid #99E0CC;
}
.support-center .support-card--qna .card-content h3 {
color: #212529;
@@ -8893,7 +8905,7 @@ button.djb-comment-submit:disabled {
padding: 10px;
min-height: 60px;
width: 178px;
background: #008ae2;
background: #4685EF;
color: #FFFFFF;
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 20px;
@@ -8903,9 +8915,9 @@ button.djb-comment-submit:disabled {
transition: all 0.3s ease;
}
.signup-cta-section .btn-signup-cta:hover {
background: rgb(0, 124.2, 203.4);
background: rgb(41.5597014925, 114.2447761194, 236.5402985075);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 138, 226, 0.3);
box-shadow: 0 4px 12px rgba(70, 133, 239, 0.3);
}
.signup-cta-section .btn-signup-cta:active {
transform: translateY(0);
@@ -8921,6 +8933,27 @@ button.djb-comment-submit:disabled {
line-height: 16px;
}
}
.signup-cta-section .cta-character {
position: absolute;
bottom: 0;
z-index: 1;
height: auto;
pointer-events: none;
user-select: none;
}
@media (max-width: 1024px) {
.signup-cta-section .cta-character {
display: none;
}
}
.signup-cta-section .cta-character-left {
left: 0;
width: clamp(180px, 21.6vw, 416px);
}
.signup-cta-section .cta-character-right {
right: 0;
width: clamp(180px, 21.1vw, 406px);
}
.signup-cta-section .cta-divider {
position: absolute;
bottom: 0;
@@ -10908,7 +10941,7 @@ button.djb-comment-submit:disabled {
}
}
.signup-selection-page {
min-height: calc(100vh - 140px);
min-height: calc(100vh - 380px);
display: flex;
align-items: center;
justify-content: center;
@@ -11441,6 +11474,7 @@ button.djb-comment-submit:disabled {
.ip-input-row {
display: flex;
align-items: center;
gap: 21px;
}
.ip-input-row .ip-input {
@@ -11468,7 +11502,7 @@ button.djb-comment-submit:disabled {
}
.btn-add-ip {
width: auto;
width: 100px;
height: 40px;
padding: 0 16px;
background-color: #3ba4ed;
@@ -11480,20 +11514,23 @@ button.djb-comment-submit:disabled {
cursor: pointer;
transition: background-color 0.2s ease;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.btn-add-ip:hover {
background-color: #2b94dd;
}
@media (max-width: 1024px) {
.btn-add-ip {
width: auto;
width: 100%;
font-size: 14px;
height: 38px;
}
}
@media (max-width: 768px) {
.btn-add-ip {
width: auto;
width: 89px;
height: 36px;
font-size: 13px;
border-radius: 8px;
@@ -11514,6 +11551,7 @@ button.djb-comment-submit:disabled {
}
.register-form-container .ip-list .ip-item {
display: flex;
align-items: center;
gap: 21px;
padding: 0;
border-bottom: none;
@@ -11542,7 +11580,6 @@ button.djb-comment-submit:disabled {
background-color: #FFFFFF;
display: flex;
align-items: center;
font-size: 20px;
color: #1A1A2E;
}
@media (max-width: 768px) {
@@ -11555,26 +11592,27 @@ button.djb-comment-submit:disabled {
}
}
.register-form-container .ip-list .ip-item .btn-remove-ip {
width: 158px !important;
height: 60px !important;
width: 100px !important;
height: 40px !important;
background-color: #e5e7eb !important;
color: #5f666c !important;
border: none !important;
border-radius: 12px !important;
font-size: 20px !important;
font-weight: 700;
border-radius: 8px !important;
font-size: 14px !important;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s ease;
flex-shrink: 0;
display: flex !important;
align-items: center;
justify-content: center;
align-self: center;
}
@media (max-width: 1024px) {
.register-form-container .ip-list .ip-item .btn-remove-ip {
width: 100% !important;
font-size: 16px !important;
height: 50px !important;
font-size: 14px !important;
height: 38px !important;
}
}
@media (max-width: 768px) {
@@ -13674,7 +13712,26 @@ input[type=checkbox]:checked + .custom-checkbox {
.form-actions-center .btn.btn-danger {
display: none;
}
.form-actions-center .btn-submit.app-modify-btn,
.form-actions-center .btn.app-modify-btn {
display: none;
}
}
.app-modify-pc-only-message {
display: none;
margin: 0;
padding: 0 20px 24px;
color: #64748B;
font-size: 14px;
text-align: center;
line-height: 1.5;
}
@media (max-width: 768px) {
.app-modify-pc-only-message {
display: block;
}
}
@media (max-width: 768px) {
.app-management-container {
padding: 24px 16px;
@@ -13831,6 +13888,21 @@ input[type=checkbox]:checked + .custom-checkbox {
}
}
.app-list-expect-date {
margin-left: auto;
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
font-weight: 400;
color: #6e7780;
white-space: nowrap;
flex-shrink: 0;
}
@media (max-width: 768px) {
.app-list-expect-date {
font-size: 12px;
}
}
.app-list-description {
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
@@ -13874,6 +13946,25 @@ input[type=checkbox]:checked + .custom-checkbox {
border-radius: 8px;
}
}
@media (max-width: 768px) {
.btn-app-create {
display: none;
}
}
.app-create-pc-only-message {
display: none;
margin: 0;
color: #64748B;
font-size: 14px;
text-align: center;
line-height: 1.5;
}
@media (max-width: 768px) {
.app-create-pc-only-message {
display: block;
}
}
.notice-page {
min-height: calc(100vh - 140px);
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 729 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 712 KiB

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 676 KiB

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

@@ -35,22 +35,22 @@
font-size: $font-size-sm;
transition: $transition-base;
// Box style for Figma design (node-id: 721-1489)
// Figma GNB 로그인 버튼: pill (#F2F5F7 배경 / #0046FF 보더·텍스트 / radius 20px)
&-box {
height: 32px;
height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px; // 아이콘-텍스트 간격
padding: 6px 16px;
color: #212529; // 강조 텍스트 색상
gap: 4px; // 아이콘-텍스트 간격 (Figma)
padding: 6px 16px 6px 12px;
color: #0046FF; // Figma 로그인 텍스트 블루
text-decoration: none;
border: none;
border-radius: 6px;
border: 1px solid #0046FF;
border-radius: 20px;
font-family: $font-family-primary;
font-weight: $font-weight-semibold; // 600
font-size: 16px;
background: transparent;
font-weight: $font-weight-regular; // 400 (Figma)
font-size: 19px;
background: #F2F5F7; // Figma 그레이2 pill 배경
transition: $transition-base;
.user-icon {
@@ -58,6 +58,10 @@
height: 18px;
flex-shrink: 0;
}
&:hover {
background: #E9EDF2;
}
}
}
@@ -40,7 +40,7 @@
gap: 16px;
.footer-logo {
width: 114px;
width: 144px; // Figma: 제주은행 로고 144x34
height: auto;
object-fit: contain;
}
@@ -53,7 +53,7 @@
.footer-link {
font-family: $font-family-primary;
font-size: 16px;
font-weight: 400;
font-weight: 700; // Figma: 링크 Bold
color: #5F666C;
text-decoration: none;
transition: color 0.3s ease;
@@ -65,7 +65,7 @@
}
.footer-separator {
color: #D1D5DB;
color: #5F666C; // Figma 구분자
font-size: 16px;
user-select: none;
}
@@ -75,7 +75,7 @@
font-family: $font-family-primary;
font-size: 14px;
font-weight: 400;
color: #9CA3AF;
color: #5F666C; // Figma: 저작권 그레이
margin: 0;
line-height: 1.5;
}
@@ -103,8 +103,8 @@
font-weight: 400;
color: #5F666C;
background: #FFFFFF;
border: 1px solid #D1D5DB;
border-radius: 6px;
border: 1px solid #5F666C; // Figma: 관련사이트 박스 보더
border-radius: 4px;
cursor: pointer;
transition: all 0.3s ease;
appearance: none;
@@ -131,14 +131,14 @@
.footer-contact {
font-family: $font-family-primary;
font-size: 18px;
font-size: 30px; // Figma: 고객센터 30px Bold
font-weight: 700;
color: #1F2937;
color: #5F666C; // Figma 그레이
margin: 0;
line-height: 1.2;
@include respond-to('md') {
font-size: 16px;
font-size: 22px;
}
}
}
@@ -127,13 +127,14 @@ body.design-survey-active {
position: relative;
background-color: #ffffff;
z-index: $z-index-header;
height: 112px;
height: 110px; // Figma GNB 높이
transition: all 0.3s ease;
border-bottom: 1px solid #dddddd;
// Index page header with light blue background
// Figma GNB: 흰색 (히어로 시안 그라데이션과 분리)
&.index-header {
background-color: #E5F8FF;
background-color: #FFFFFF;
border-bottom: none;
}
.container {
height: 100%;
@@ -201,10 +202,10 @@ body.design-survey-active {
}
.logo-text {
font-size: 22px;
font-weight: 600;
font-size: 23px; // Figma: API Portal 23px Medium
font-weight: 500;
line-height: 32px;
color: var(--text-dark);
color: #000000;
text-decoration: none;
display: inline-flex;
align-items: center;
@@ -984,8 +985,8 @@ body.design-survey-active {
align-items: center;
gap: 6px;
text-decoration: none;
color: var(--text-dark);
font-weight: 500;
color: #212529; // Figma 강조텍스트
font-weight: 700; // Figma: 네비 Bold
font-size: 20px;
padding: 8px 16px;
border-radius: 8px;
@@ -876,6 +876,26 @@
// 모바일에서는 2개 버튼만 표시되는 경우 (이전, 수정)
display: none;
}
// 모바일: 수정 마법사 UI 표현이 어려워 버튼 숨기고 PC 안내 노출
&.app-modify-btn {
display: none;
}
}
}
}
// PC 환경 안내 문구 - 모바일에서만 노출 ( 수정)
.app-modify-pc-only-message {
display: none;
margin: 0;
padding: 0 20px 24px;
color: $text-gray;
font-size: 14px;
text-align: center;
line-height: 1.5;
@media (max-width: $breakpoint-sm) {
display: block;
}
}
@@ -194,6 +194,22 @@
}
}
// Expected Completion Date (pending requests only)
.app-list-expect-date {
margin-left: auto;
font-family: $font-family-primary;
font-size: 14px;
font-weight: $font-weight-regular;
color: #6e7780;
white-space: nowrap;
flex-shrink: 0;
// Figma 모바일: 12px
@include respond-to('sm') {
font-size: 12px;
}
}
// App Description
.app-list-description {
font-family: $font-family-primary;
@@ -246,4 +262,23 @@
font-size: 14px;
border-radius: 8px;
}
// 모바일: 생성 마법사 UI 표현이 어려워 버튼 숨기고 PC 안내 노출
@include respond-to('sm') {
display: none;
}
}
// PC 환경 안내 문구 - 모바일에서만 노출
.app-create-pc-only-message {
display: none;
margin: 0;
color: $text-gray;
font-size: 14px;
text-align: center;
line-height: 1.5;
@include respond-to('sm') {
display: block;
}
}
@@ -370,6 +370,7 @@
// IP Input Row - Step 1 Style
.ip-input-row {
display: flex;
align-items: center;
gap: 21px;
.ip-input {
@@ -398,7 +399,7 @@
}
.btn-add-ip {
width: auto;
width: 100px;
height: 40px;
padding: 0 16px;
background-color: #3ba4ed;
@@ -410,20 +411,23 @@
cursor: pointer;
transition: background-color 0.2s ease;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background-color: #2b94dd;
}
@include respond-to('md') {
width: auto;
width: 100%;
font-size: 14px;
height: 38px;
}
// Figma 모바일 (1290-3137): 89px × 40px, border-radius 8px, 12px bold
@include respond-to('sm') {
width: auto;
width: 89px;
height: 36px;
font-size: 13px;
border-radius: 8px;
@@ -446,6 +450,7 @@
.ip-item {
display: flex;
align-items: center;
gap: 21px;
padding: 0;
border-bottom: none;
@@ -474,7 +479,6 @@
background-color: $white;
display: flex;
align-items: center;
font-size: 20px;
color: $text-dark;
// Figma 모바일: 40px 높이, 14px 폰트
@@ -488,25 +492,26 @@
}
.btn-remove-ip {
width: 158px !important;
height: 60px !important;
width: 100px !important;
height: 40px !important;
background-color: #e5e7eb !important;
color: #5f666c !important;
border: none !important;
border-radius: $border-radius-lg !important;
font-size: 20px !important;
font-weight: $font-weight-bold;
border-radius: $border-radius-md !important;
font-size: 14px !important;
font-weight: $font-weight-semibold;
cursor: pointer;
transition: background-color 0.2s ease;
flex-shrink: 0;
display: flex !important;
align-items: center;
justify-content: center;
align-self: center;
@include respond-to('md') {
width: 100% !important;
font-size: 16px !important;
height: 50px !important;
font-size: 14px !important;
height: 38px !important;
}
// Figma 모바일 (1290-3137): 89px × 40px, border-radius 8px, 12px bold
+272 -225
View File
@@ -2,6 +2,36 @@
@use '../abstracts/color-functions' as *;
@use '../abstracts/mixins' as *;
// -----------------------------------------------------------------------------
// 캐러셀 + 검색 + 추천: 이어지는 단일 배경 (Figma 연속 배경)
// - 상단 시안 그라데이션(히어로) 연한 시안(#CFF3FF)으로 흐른
// - 하단에 파도/버블 이미지(추천) 같은 #CFF3FF 상단색으로 이음새 없이 연결
// -----------------------------------------------------------------------------
.landing-top {
position: relative;
// Figma에서 추출한 단일 연속 배경 이미지 (히어로 그라데이션 + 곡선 + 추천 파도/버블)
background-color: #D4F0FC;
background-image: url('/img/bg_landing_top.png');
background-repeat: no-repeat;
background-size: 100% 100%;
background-position: top center;
// 하위 섹션 배경 투명 wrapper 배경이 끊김 없이 이어지도록
.hero-carousel-section,
.hero-carousel-container,
.hero-slide,
.api-search-section,
.api-showcase {
background: transparent !important;
}
// 개별 섹션 배경 레이어 제거 (wrapper로 통합)
.showcase-background,
.search-background {
display: none !important;
}
}
// -----------------------------------------------------------------------------
// Hero Carousel Section - Figma Design
// -----------------------------------------------------------------------------
@@ -17,7 +47,8 @@
width: 100%;
height: 580px;
overflow: hidden;
background-color: #E9F9FF;
// Figma 히어로 배경: 상단 연한 시안 하단 스카이 시안 (실측 픽셀 기준)
background: linear-gradient(180deg, #BAF5FA 0%, #76D4FC 100%);
@include respond-to('md') {
height: 800px;
@@ -60,15 +91,15 @@
// Slide backgrounds
&[data-slide="0"] {
background: #E9F9FF;
background: transparent;
}
&[data-slide="1"] {
background: #E9F9FF;
background: transparent;
}
&[data-slide="2"] {
background: #E9F9FF;
background: transparent;
}
}
@@ -176,7 +207,7 @@
width: 178px;
height: 60px;
padding: 10px;
background: #0049B4;
background: #2A69DE;
color: #FFFFFF;
font-family: $font-family-primary;
font-size: 20px;
@@ -186,9 +217,9 @@
transition: all 0.3s ease;
&:hover {
background: darken(#0049B4, 10%);
background: darken(#2A69DE, 10%);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 73, 180, 0.3);
box-shadow: 0 4px 12px rgba(42, 105, 222, 0.3);
}
&:active {
@@ -421,11 +452,13 @@
.api-search-section {
position: relative;
padding: 0;
//margin-top: -80px;
overflow: hidden;
padding: 56px 20px 64px;
overflow: visible;
background: #e9f9ff;
height: 314px;
@include respond-to('sm') {
padding: 28px 16px 32px;
}
.search-background {
position: absolute;
@@ -437,111 +470,153 @@
pointer-events: none;
}
.search-content-wrapper {
// Figma: 글래스모피즘 카드 (반투명 화이트 + 보더 + 섀도, radius 30)
.search-card {
position: relative;
z-index: 1;
max-width: 1308px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
gap: 40px;
z-index: 1;
gap: 56px;
padding: 40px 56px;
border-radius: 30px;
background: rgba(255, 255, 255, 0.45);
border: 1px solid #FFFFFF;
box-shadow: 0 10px 24px rgba(141, 155, 185, 0.25);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
@include respond-to('md') {
flex-direction: column;
align-items: flex-start;
gap: 24px;
padding-top: 40px;
padding: 32px;
}
@include respond-to('sm') {
// Figma 모바일: 캐릭터와 텍스트 가로 배치
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
padding-top: 20px;
justify-content: center;
}
}
.search-character {
width: 154px;
height: 148px;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: contain;
}
@include respond-to('md') {
width: 120px;
height: 115px;
}
@include respond-to('sm') {
// Figma 모바일: 68x65px
width: 68px;
height: 65px;
gap: 16px;
padding: 20px;
border-radius: 20px;
}
}
.search-text-content {
text-align: left;
flex-shrink: 0;
@include respond-to('md') {
text-align: center;
}
@include respond-to('sm') {
// Figma 모바일: 텍스트 왼쪽 정렬
text-align: left;
width: 100%;
}
}
.search-title {
font-size: 36px;
line-height: 1.3;
font-weight: 500;
line-height: 1.25;
color: #000000;
margin: 0;
white-space: nowrap;
@include respond-to('md') {
font-size: 28px;
}
@include respond-to('sm') {
// Figma 모바일: 16px, Bold
font-size: 16px;
line-height: 1.4;
line-height: 1.3;
color: #212529;
white-space: normal;
}
}
.search-main {
flex: 1;
min-width: 0;
width: 100%;
display: flex;
flex-direction: column;
gap: 18px;
@include respond-to('sm') {
gap: 12px;
}
}
// 자주찾는 API + 보라 알약 태그
.hashtag-section {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
@include respond-to('sm') {
gap: 8px;
}
}
.hashtag-label {
font-family: $font-family-primary;
font-size: 16px;
font-weight: 700;
color: #000000;
white-space: nowrap;
@include respond-to('sm') {
font-size: 12px;
color: #212529;
}
}
.search-input-wrapper {
position: relative;
.hashtag-list {
display: flex;
justify-content: center;
padding: 0px 0 30px;
z-index: 1;
align-items: center;
gap: 10px;
flex-wrap: wrap;
@include respond-to('md') {
padding: 30px 20px 20px;
@include respond-to('sm') {
gap: 6px;
}
}
// Figma: 보라색 알약 태그 (#4546CE)
.hashtag-link {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 16px;
background: #4546CE;
color: #FFFFFF;
border-radius: 999px;
font-family: $font-family-primary;
font-size: 13px;
font-weight: 700;
text-decoration: none;
white-space: nowrap;
transition: background 0.2s ease, transform 0.2s ease;
&:hover {
background: darken(#4546CE, 8%);
transform: translateY(-1px);
text-decoration: none;
}
@include respond-to('sm') {
// Figma 모바일: 좌측 27px, 우측 28px 패딩으로 좌측 정렬
justify-content: flex-start;
padding: 0px 28px 12px 27px;
padding: 4px 10px;
font-size: 11px;
}
}
// 알약 태그라 텍스트 구분자(|) 숨김
.hashtag-separator {
display: none;
}
.search-input-wrapper {
width: 100%;
}
.search-form {
width: 100%;
max-width: 816px;
@include respond-to('sm') {
// Figma 모바일: 320px 고정 너비
//width: 320px;
//max-width: 320px;
}
}
.search-box {
@@ -556,16 +631,15 @@
padding: 0 24px;
@include respond-to('md') {
height: 60px;
border: 4px solid #0049B4;
height: 64px;
border-width: 4px;
padding: 0 16px;
}
@include respond-to('sm') {
// Figma 모바일: 320x36px, 1px 파란색 테두리
height: 36px;
border: 3px solid #0049B4;
border-radius: 18px;
height: 44px;
border-width: 3px;
border-radius: 22px;
padding: 0 12px;
}
}
@@ -581,7 +655,7 @@
font-size: 17px;
font-weight: 500;
color: #000000;
padding: 0 20px;
padding: 0 12px;
&::placeholder {
color: #B3B3B3;
@@ -590,18 +664,13 @@
@include respond-to('md') {
font-size: 15px;
padding: 0 12px;
}
@include respond-to('sm') {
// Figma 모바일: 11px, Medium, placeholder 색상 #8C959F
font-size: 11px;
font-weight: 500;
padding: 0 8px;
font-size: 12px;
&::placeholder {
color: #8C959F;
font-weight: 500;
}
}
}
@@ -637,119 +706,14 @@
}
@include respond-to('sm') {
// Figma 모바일: 16x16px 아이콘
width: 16px;
height: 16px;
width: 18px;
height: 18px;
}
}
@include respond-to('sm') {
width: 20px;
height: 20px;
}
}
.hashtag-section {
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 0 20px 0px;
z-index: 1;
@include respond-to('md') {
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 20px 30px;
}
@include respond-to('sm') {
// Figma 모바일: 가로 배치, 작은 간격
flex-direction: row;
flex-wrap: wrap;
gap: 12px;
padding: 0 20px 20px;
justify-content: center;
}
}
.hashtag-label {
font-family: $font-family-primary;
font-size: 16px;
font-weight: 700;
color: #000000;
white-space: nowrap;
@include respond-to('sm') {
// Figma 모바일: 12px, Bold
font-size: 12px;
color: #212529;
}
}
.hashtag-list {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
@include respond-to('md') {
justify-content: center;
gap: 10px;
}
@include respond-to('sm') {
// Figma 모바일: 8px 간격, wrap
gap: 8px;
justify-content: left;
}
}
.hashtag-link {
font-family: $font-family-primary;
font-size: 16px;
font-weight: 400;
color: #000000;
text-decoration: none;
white-space: nowrap;
transition: color 0.3s ease;
&:hover {
color: #0049B4;
text-decoration: underline;
}
@include respond-to('md') {
font-size: 14px;
}
@include respond-to('sm') {
// Figma 모바일: 12px
font-size: 12px;
color: #212529;
}
}
.hashtag-separator {
font-family: $font-family-primary;
font-size: 16px;
color: #000000;
user-select: none;
@include respond-to('md') {
font-size: 14px;
}
@include respond-to('sm') {
// Figma 모바일: 세로 구분선 (|)
font-size: 10px;
color: #212529;
display: flex;
align-items: center;
height: 10px;
width: 24px;
height: 24px;
}
}
}
@@ -863,7 +827,7 @@
font-size: 15px;
font-weight: 500;
color: #FFFFFF;
background: #008ae2;
background: #2A69DE;
text-decoration: none;
border-radius: 10px;
transition: all 0.3s ease;
@@ -910,7 +874,7 @@
}
&:hover {
background: darken(#008ae2, 10%);
background: darken(#2A69DE, 10%);
svg {
transform: translateX(4px);
@@ -952,14 +916,49 @@
flex-direction: column;
align-items: flex-start;
position: relative;
transition: all 0.3s ease;
overflow: hidden;
transition: transform 0.3s ease, box-shadow 0.3s ease, border-color 0.3s ease;
gap: 0px;
cursor: pointer;
// Figma: hover 파란 세로 그라데이션 (카드4 = hover 예시 상태)
// 그라데이션은 transition 직접 적용 불가 오버레이 opacity로 over/out 부드럽게 페이드
&::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(180deg, #50CAFF 0%, #2088FF 100%);
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
z-index: 0;
}
// 콘텐츠를 오버레이 위로
> * {
position: relative;
z-index: 1;
}
.card-title,
.card-description {
transition: color 0.3s ease;
}
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
border-color: #008ae2;
border-color: transparent;
box-shadow: 0 10px 28px rgba(0, 14, 27, 0.22);
&::before {
opacity: 1;
}
.card-title,
.card-description {
color: #FFFFFF;
}
}
@include respond-to('md') {
@@ -1043,7 +1042,7 @@
// -----------------------------------------------------------------------------
// -----------------------------------------------------------------------------
// Info Section - 주은행 API 소개 (Figma Design)
// Info Section - 주은행(DJBank) API 소개 (Figma Design)
// -----------------------------------------------------------------------------
.info-section {
position: relative;
@@ -1060,13 +1059,28 @@
padding: 75px 20px 20px;
}
// Figma 배경 이미지: 좌측 블루 카드(우하단 라운드 baked), 우측은 영역(모니터)
.info-background {
position: absolute;
inset: 0;
background-image: url('/img/bg_api_intro.svg');
background-size: cover;
background-position: center;
top: 0;
bottom: 0;
left: 0;
width: 54%; // 텍스트(좌측 콘텐츠) 폭을 수용하면서 모니터와 겹치지 않는
background-image: url('/img/bg_diff_card.png');
background-size: 100% 100%;
background-repeat: no-repeat;
background-position: left top;
pointer-events: none;
@include respond-to('md') {
// 태블릿: 컬럼 레이아웃 전체 블루
width: 100%;
}
@include respond-to('sm') {
// 모바일: 배경 + 다크 텍스트(기존 sm 오버라이드) 유지
display: none;
}
}
//.info-point-image {
@@ -1131,7 +1145,7 @@
.info-title {
font-size: 28px;
line-height: 1.4;
color: #000000;
color: #FFFFFF; // Figma: 블루 카드 텍스트
margin: 0 0 $spacing-lg 0;
@include respond-to('md') {
@@ -1154,7 +1168,7 @@
.title-highlight {
font-weight: 700;
color: #0049B4;
color: #0FDBFF; // Figma: 브랜드 라인 시안 강조
font-size: 36px;
@include respond-to('sm') {
@@ -1172,7 +1186,7 @@
font-size: 20px;
font-weight: 400;
line-height: 1.6;
color: #000000;
color: #FFFFFF; // Figma: 블루 카드 텍스트
margin: 0 0 $spacing-3xl 0;
@include respond-to('md') {
@@ -1210,17 +1224,17 @@
display: inline-flex;
align-items: center;
justify-content: center;
padding: 18px 52px;
padding: 0 28px;
font-family: $font-family-primary;
font-size: 24px;
font-size: 18px; // Figma: 18px Bold
font-weight: 700;
line-height: 56px;
border-radius: 20px;
line-height: 1.2;
border-radius: 14px;
text-decoration: none;
white-space: nowrap;
transition: all 0.3s ease;
min-width: 310px;
height: 128px;
min-width: 0;
height: 60px; // Figma: 버튼 높이 ~71px 카드 적정 60px
@include respond-to('sm') {
// Figma 모바일: 160x72px, 14px Bold, border-radius 8px
@@ -1251,27 +1265,32 @@
}
}
// Figma: '처음 만나는 DJBank API' = 연한 파랑 배경 + 네이비 텍스트
.action-btn-primary {
background: #0049B4;
color: #FFFFFF;
background: #D5EFFF;
color: #211894;
&:hover {
background: darken(#0049B4, 10%);
background: darken(#D5EFFF, 8%);
}
}
// Figma: '피드백/개선요청' = 진한 블루 배경 + 텍스트, 테두리 없음
.action-btn-secondary {
background: #00ACDD;
background: #0046FF;
color: #FFFFFF;
border: none;
// hover 반전: 배경 + 블루 텍스트
&:hover {
background: darken(#00ACDD, 10%);
background: #FFFFFF;
color: #0046FF;
}
}
.info-image {
flex: 1;
max-width: 560px;
flex: 0 1 460px;
max-width: 460px; // 모니터 일러스트 축소 블루 카드 영역 침범 방지
display: flex;
align-items: center;
justify-content: center;
@@ -1331,6 +1350,8 @@
}
.support-background {
// Figma: 깔끔한 #eef7fd 단색 배경. 광주은행 장식 이미지 제거.
display: none;
position: absolute;
top: 50%;
left: 50%;
@@ -1696,7 +1717,8 @@
// FAQ Card Specifics
&--faq {
background: #DAF0FF;
background: #CAEAFF;
border: 1px solid #97D5FF;
.card-content h3 {
color: #212529;
@@ -1705,7 +1727,8 @@
// Q&A Card Specifics
&--qna {
background: #D9F6F8;
background: #C7F6F9;
border: 1px solid #99E0CC;
.card-content h3 {
color: #212529;
@@ -2074,7 +2097,7 @@
padding: 10px;
min-height: 60px;
width: 178px;
background: #008ae2;
background: #4685EF;
color: #FFFFFF;
font-family: $font-family-primary;
font-size: 20px;
@@ -2084,9 +2107,9 @@
transition: all 0.3s ease;
&:hover {
background: darken(#008ae2, 10%);
background: darken(#4685EF, 10%);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 138, 226, 0.3);
box-shadow: 0 4px 12px rgba(70, 133, 239, 0.3);
}
&:active {
@@ -2105,6 +2128,30 @@
}
}
// Figma 1:236 / 3D 캐릭터 일러스트 (image4 / image6)
.cta-character {
position: absolute;
bottom: 0;
z-index: 1;
height: auto;
pointer-events: none;
user-select: none;
@include respond-to('md') {
display: none;
}
}
.cta-character-left {
left: 0;
width: clamp(180px, 21.6vw, 416px);
}
.cta-character-right {
right: 0;
width: clamp(180px, 21.1vw, 406px);
}
.cta-divider {
position: absolute;
bottom: 0;
@@ -7,7 +7,7 @@
// -----------------------------------------------------------------------------
.signup-selection-page {
min-height: calc(100vh - 140px); // Account for header and footer
min-height: calc(100vh - 380px); // Account for header and footer
display: flex;
align-items: center;
justify-content: center;
@@ -5,6 +5,8 @@
<body>
<th:block layout:fragment="contentFragment">
<!-- 캐러셀 + 검색 + 추천: 이어지는 배경 그룹 -->
<div class="landing-top">
<!-- Hero Carousel Section -->
<section class="hero-carousel-section">
<!-- Carousel Container -->
@@ -96,21 +98,27 @@
<!-- API 검색 섹션 -->
<section class="api-search-section">
<div class="search-background"></div>
<div class="search-content-wrapper">
<div class="search-character">
<img th:src="@{/img/img_search_character.png}" alt="검색">
</div>
<div class="search-card">
<div class="search-text-content">
<h2 class="search-title">
원하는 API를<br>
지금 검색해 보세요.
</h2>
</div>
</div>
<div class="search-input-wrapper">
<div class="search-main">
<div class="hashtag-section">
<span class="hashtag-label">자주찾는 API</span>
<div class="hashtag-list" th:if="${hashtags != null and not #lists.isEmpty(hashtags)}">
<th:block th:each="hashtag, iterStat : ${hashtags}">
<a href="#" class="hashtag-link" th:data-search="${hashtag}" th:text="${'#' + hashtag}"></a>
<span class="hashtag-separator" th:unless="${iterStat.last}">|</span>
</th:block>
</div>
</div>
<div class="search-input-wrapper">
<form th:action="@{/apis}" method="get" class="search-form">
<div class="search-box">
<input type="text" class="search-input" placeholder="어떤 API를 고 계신가요?" id="apiSearchInput" name="keyword">
<input type="text" class="search-input" placeholder="어떤 API를 고 계신가요?" id="apiSearchInput" name="keyword">
<button class="search-icon-button" type="submit">
<svg width="33" height="34" viewBox="0 0 33 34" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.1738 0C23.5534 0.000258465 30.3465 6.79327 30.3467 15.1729C30.3467 18.6141 29.2001 21.7875 27.2695 24.333C27.2733 24.3367 27.2775 24.34 27.2812 24.3438L32.1348 29.1973C33.1223 30.1849 33.1223 31.7859 32.1348 32.7734C31.1472 33.7609 29.5461 33.7609 28.5586 32.7734L23.7051 27.9199C23.6653 27.8802 23.6293 27.8376 23.5928 27.7959C21.1837 29.406 18.2889 30.3466 15.1738 30.3467C6.79392 30.3467 0 23.5528 0 15.1729C0.000201956 6.79311 6.79404 0 15.1738 0ZM15.1738 5.05762C9.58735 5.05762 5.05782 9.58642 5.05762 15.1729C5.05762 20.7595 9.58722 25.2891 15.1738 25.2891C20.7602 25.2888 25.2891 20.7593 25.2891 15.1729C25.2889 9.58658 20.7601 5.05788 15.1738 5.05762Z" fill="#0049B4"/>
@@ -118,15 +126,7 @@
</button>
</div>
</form>
</div>
<div class="hashtag-section">
<span class="hashtag-label">자주찾는 API</span>
<div class="hashtag-list" th:if="${hashtags != null and not #lists.isEmpty(hashtags)}">
<th:block th:each="hashtag, iterStat : ${hashtags}">
<a href="#" class="hashtag-link" th:data-search="${hashtag}" th:text="${'#' + hashtag}">
</a>
<span class="hashtag-separator" th:unless="${iterStat.last}">|</span>
</th:block>
</div>
</div>
</div>
</section>
@@ -136,8 +136,8 @@
<div class="showcase-background"></div>
<div class="showcase-wrapper">
<div class="section-header">
<h2 class="section-title">추천 API</h2>
<p class="section-subtitle">다양한 금융 API상품을 소개합니다.</p>
<h2 class="section-title">추천 API 그룹</h2>
<p class="section-subtitle">다양한 금융 API를 제공합니다.</p>
<a th:href="@{/apis}" class="btn-all-apis">
전체보기
<svg width="9" height="15" viewBox="0 0 9 15" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -150,7 +150,7 @@
<!-- Services 데이터를 반복하여 API 카드 표시 (최대 4개) -->
<div class="api-card" th:each="service, iterStat : ${services}"
th:if="${iterStat.index < 4}"
th:onclick="${service.id != null} ? |location.href='@{/apis(groupId=${service.id})}'| : |location.href='@{/apis}'|">
th:onclick="${service.id != null} ? |location.href='@{/apis(groupIds=${service.id})}'| : |location.href='@{/apis}'|">
<h3 class="card-title" th:text="${service.groupName}">API 서비스</h3>
<p class="card-description">
<span th:if="${service.groupDesc != null and !#strings.isEmpty(service.groupDesc)}"
@@ -168,6 +168,8 @@
</div>
</div>
</section>
</div>
<!-- /landing-top -->
<!-- 정보 섹션 -->
<section class="info-section">
@@ -279,7 +281,7 @@
<div class="stats-divider"></div>
<div class="stat-card">
<p class="stat-label">API 이용 건수</p>
<p class="stat-label">API 이용 건수(지난달)</p>
<div class="stat-icon">
<svg width="70" height="70" viewBox="0 0 70 70" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<rect width="70" height="70" fill="url(#pattern0_3001_94)"/>
@@ -318,6 +320,8 @@
<!-- 지금 바로 회원가입 CTA 섹션 -->
<section class="signup-cta-section">
<div class="cta-background"></div>
<img th:src="@{/img/cta_character_left.png}" alt="" class="cta-character cta-character-left">
<img th:src="@{/img/cta_character_right.png}" alt="" class="cta-character cta-character-right">
<div class="container">
<div class="cta-content">
<h2 class="cta-title">DJBank API를 지금 바로 만나보세요.</h2>
@@ -48,6 +48,12 @@
</span>
<!-- App Name -->
<h3 class="app-list-name" th:text="${request.clientName}">앱 이름</h3>
<!-- Expected Completion Date -->
<span class="app-list-expect-date"
th:if="${request.approval != null and request.approval.expectEndDate != null and #strings.length(request.approval.expectEndDate) >= 8}"
th:text="|예상 완료일 : ${#strings.substring(request.approval.expectEndDate,4,6)}월 ${#strings.substring(request.approval.expectEndDate,6,8)}일 (${request.approval.expectEndDateDayOfWeek})|">
예상 완료일 : 05월 30일 (토)
</span>
</div>
<!-- App Description -->
<p class="app-list-description"
@@ -116,6 +122,9 @@
<button type="button" class="btn-app-create" id="requestApiKey">
앱 생성
</button>
<p class="app-create-pc-only-message">
앱 생성은 PC 환경에서 이용해 주세요.
</p>
</div>
</section>
@@ -166,7 +166,7 @@
</div>
</div>
<!-- Callback URL -->
<!-- Callback URL : OAuth2 3-legged 전용. 현재 미사용으로 주석 처리 (추후 OAuth2 도입 시 복원)
<div class="form-row">
<div class="form-label-wrapper label-offset">
<span class="form-label-text">Call Back URL</span>
@@ -180,11 +180,13 @@
placeholder="URL을 입력해 주세요.">
</div>
</div>
-->
<!-- IP Whitelist -->
<div class="form-row">
<div class="form-label-wrapper label-offset">
<span class="form-label-text">화이트리스트</span>
<span class="required-badge">필수</span>
</div>
<div class="form-field-wrapper">
<div class="ip-input-row">
@@ -458,7 +460,8 @@
form.addEventListener('submit', function(e) {
const appName = document.getElementById('appName').value.trim();
const appDescription = document.getElementById('appDescription').value.trim();
const callbackUrl = document.getElementById('callbackUrl').value.trim();
// callbackUrl: OAuth2 3-legged 전용. 현재 미사용으로 주석 처리.
// const callbackUrl = document.getElementById('callbackUrl').value.trim();
if (!appName) {
e.preventDefault();
@@ -474,22 +477,30 @@
return false;
}
// Validate URL format only if callbackUrl is provided (optional field)
if (callbackUrl && callbackUrl.trim() !== '') {
try {
new URL(callbackUrl);
} catch (error) {
e.preventDefault();
customPopups.showAlert('올바른 URL 형식이 아닙니다.\n예: https://example.com/api/callback');
document.getElementById('callbackUrl').focus();
return false;
}
// 화이트리스트 필수
if (ipAddresses.length === 0) {
e.preventDefault();
customPopups.showAlert('화이트리스트 IP를 1개 이상 등록해주세요.');
document.getElementById('ipWhitelistInput').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();
// customPopups.showAlert('올바른 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_callbackUrl', callbackUrl);
sessionStorage.setItem('registration_ipWhitelist', ipAddresses.join(','));
});
});
@@ -72,7 +72,8 @@
</div>
</div>
<!-- Callback URL -->
<!-- Callback URL (주석 처리) -->
<!--/*
<div class="info-row">
<label class="info-label">Callback URL</label>
<div class="info-value">
@@ -81,6 +82,7 @@
</div>
</div>
</div>
*/-->
<!-- IP Whitelist -->
<div class="info-row">
@@ -120,6 +122,18 @@
</div>
</div>
<!-- Expected Completion Date (if set by admin) -->
<div class="info-row"
th:if="${appRequest.approval != null and appRequest.approval.expectEndDate != null and #strings.length(appRequest.approval.expectEndDate) >= 8}">
<label class="info-label">예상 완료일</label>
<div class="info-value">
<div class="info-value-box"
th:text="|${#strings.substring(appRequest.approval.expectEndDate,0,4)}.${#strings.substring(appRequest.approval.expectEndDate,4,6)}.${#strings.substring(appRequest.approval.expectEndDate,6,8)} (${appRequest.approval.expectEndDateDayOfWeek})|">
2025.05.30 (토)
</div>
</div>
</div>
<!-- Approval Date (if approved) -->
<div class="info-row" th:if="${appRequest.approval != null and appRequest.approval.approvalDate != null}">
<label class="info-label">승인일시</label>
@@ -96,7 +96,8 @@
</div>
</div>
<!-- Callback URL -->
<!-- Callback URL (주석 처리) -->
<!--/*
<div class="info-row">
<label class="info-label">Callback URL</label>
<div class="info-value">
@@ -105,6 +106,7 @@
</div>
</div>
</div>
*/-->
<!-- IP Whitelist -->
<div class="info-row">
@@ -159,11 +161,14 @@
인증키 삭제
</button>
<a sec:authorize="hasRole('ROLE_API_KEY_REQUEST')"
class="btn btn-submit btn-primary"
class="btn btn-submit btn-primary app-modify-btn"
th:href="@{/myapikey/modify/step1(clientId=${apiKey.clientid})}">
수정
</a>
</div>
<p class="app-modify-pc-only-message" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')">
앱 수정은 PC 환경에서 이용해 주세요.
</p>
</section>
@@ -129,6 +129,14 @@
}
};
// 제출 대상 org 필드 (검증 필수목록과 별개 - 선택입력 포함 전 필드 전송)
// 제외: ipWhitelist(가입 미입력), orgCode(관리자/시스템 부여)
const submitOrgFields = [
'compRegNo', 'corpRegNo', 'orgName', 'compRegFile', 'files',
'ceoName', 'orgAddr', 'serviceName', 'orgPhoneNumber', 'scPhoneNumber',
'orgSectors', 'orgIndustryType'
];
// 이메일 유효성 검사 함수
function isValidEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -177,7 +185,7 @@
}
});
fields.org.forEach(fieldId => {
submitOrgFields.forEach(fieldId => {
const element = document.getElementById(fieldId);
if (element) {
if (element.type === 'file') {
@@ -375,6 +383,35 @@
return scenarioValidation && commonValidation;
}
// 미입력/미충족 항목을 구체적으로 수집
function collectFormErrors() {
const errors = [];
// 시나리오별 항목
if (registrationScenario === 'new') {
const loginIdElement = document.getElementById('loginId');
const userNameElement = document.getElementById('userName');
if (!(loginIdElement && isValidEmail(loginIdElement.value))) errors.push('이메일 아이디를 올바르게 입력해주세요.');
if (!(userNameElement && userNameElement.value.trim() !== '')) errors.push('성명을 입력해주세요.');
if (!isAuthVerified) errors.push('휴대폰 인증을 완료해주세요.');
if (document.getElementById('isPasswordValid')?.value.trim() !== 'true') errors.push('비밀번호 조건을 확인해주세요.');
if (document.getElementById('isPasswordMatch')?.value.trim() !== 'true') errors.push('비밀번호 확인이 일치하지 않습니다.');
}
// 공통 항목
const notificationConsentEl = document.getElementById('notificationConsent');
if ($('#orgName').val().trim() === '') errors.push('회사명을 입력해주세요.');
if ($('#compRegNo').val().trim() === '') errors.push('사업자등록번호를 올바르게 입력해주세요.');
if ($('#corpRegNo').val().trim() === '') errors.push('법인등록번호를 올바르게 입력해주세요.');
if ($('#compRegFile').val().trim() === '') errors.push('사업자등록증 파일을 첨부해주세요.');
if (!$('#termsOfUse').prop('checked')) errors.push('이용약관에 동의해주세요.');
if (!$('#privacyCollect').prop('checked')) errors.push('개인정보 수집·이용에 동의해주세요.');
if (notificationConsentEl && !notificationConsentEl.checked) errors.push('알림 수신에 동의해주세요.');
return errors;
}
let isSubmitting = false;
// 등록 버튼 클릭 이벤트
@@ -426,7 +463,10 @@
isPasswordMatch
});
let errorMessage = '모든 필수 항목을 입력하고 약관에 동의해주세요.'
const errors = collectFormErrors();
let errorMessage = errors.length > 0
? '아래 항목을 확인해주세요.<br><br>' + errors.map(e => '• ' + e).join('<br>')
: '모든 필수 항목을 입력하고 약관에 동의해주세요.';
customPopups.showAlert(errorMessage);
// Reset submission state since validation failed
@@ -7,7 +7,7 @@
<th:block th:fragment="agreementContent(termsOfUse, privacyCollect)">
<!-- Agreement Section Header -->
<div class="org-section-header org-section-header--agreement">
<h3>약관 동의</h3>
<h3 th:text="${agreementTitle} ?: '약관 동의'">약관 동의</h3>
<span class="required-badge">필수 동의</span>
</div>
@@ -32,16 +32,6 @@
</p>
</div>
<div class="result-info-box" style="background-color: #fff3cd; border-color: #ffc107; margin-top: 16px;">
<p class="info-text" style="margin: 0;">
<strong>초대코드:</strong>
<span th:text="${invitationCode}" style="font-family: monospace; font-size: 18px; font-weight: bold; color: #333; letter-spacing: 3px; margin-left: 8px;"></span>
</p>
<p style="margin: 8px 0 0 0; font-size: 12px; color: #856404;">
이메일로 받은 초대코드와 일치하는지 확인해 주세요.
</p>
</div>
<div class="result-info-box" style="background-color: #e7f3ff; border-color: #1a73e8; margin-top: 16px;">
<p class="info-text" style="margin: 0; color: #1a73e8;">
<i class="fas fa-info-circle"></i>
@@ -43,15 +43,6 @@
</p>
</div>
<div class="result-info-box" style="background-color: #fff3cd; border-color: #ffc107; margin-top: 16px;">
<p class="info-text" style="margin: 0;">
<strong>초대코드:</strong>
<span th:text="${invitationCode}" style="font-family: monospace; font-size: 18px; font-weight: bold; color: #333; letter-spacing: 3px; margin-left: 8px;"></span>
</p>
<p style="margin: 8px 0 0 0; font-size: 12px; color: #856404;">
이메일로 받은 초대코드와 일치하는지 확인해 주세요.
</p>
</div>
</div>
</div>
@@ -82,6 +82,12 @@
<script th:src="@{/plugins/summernote/summernote-lite.min.js}"></script>
<script th:src="@{/plugins/summernote/summernote-cleaner.js}"></script>
<script th:src="@{/plugins/jquery-ui/jquery-ui.min.js}"></script>
<!-- LiveReload (개발 전용): DevTools 가 정적 리소스/템플릿 변경을 감지하면 브라우저를 자동 새로고침한다.
localhost 외 IP(예: 172.30.1.14)로 접속해도 동작하도록 접속 호스트 기준으로 livereload.js 를 로드한다.
prod/stage 에는 DevTools 자체가 없으므로(developmentOnly) 개발 프로파일에서만 주입한다. -->
<script th:if="${@environment.acceptsProfiles('local_rinjaemac','local')}"
th:src="|//${#request.serverName}:35729/livereload.js|"></script>
</head>
</html>
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.apps.user;
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import com.eactive.apim.portal.apps.user.controller.AccountController;
import com.eactive.apim.portal.apps.user.dto.PortalUserDTO;
import com.eactive.apim.portal.apps.user.facade.AuthFacade;
@@ -57,6 +58,9 @@ class AccountControllerTest {
@Mock
private UserInvitationRepository userInvitationRepository;
@Mock
private UserSessionService userSessionService;
@InjectMocks
private AccountController accountController;
@@ -127,7 +131,7 @@ class AccountControllerTest {
securityUtil.when(SecurityUtil::getPortalAuthenticatedUser).thenReturn(authenticatedUser);
when(userFacade.findById("user-1")).thenReturn(dto);
when(userInvitationRepository.findFirstByInvitationMobileAndStatus(
"01012345678", InvitationStatus.PENDING))
"010-1234-5678", InvitationStatus.PENDING))
.thenReturn(Optional.empty());
mockMvc.perform(get("/mypage"))
@@ -0,0 +1,70 @@
package com.eactive.apim.portal.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class PhoneNumberUtilTest {
@Test
@DisplayName("휴대폰 11자리 → 010-XXXX-XXXX (멱등)")
void mobile11() {
assertEquals("010-1234-5678", PhoneNumberUtil.normalize("01012345678"));
assertEquals("010-1234-5678", PhoneNumberUtil.normalize("010-1234-5678"));
assertEquals("010-9912-0000", PhoneNumberUtil.normalize("01099120000"));
}
@Test
@DisplayName("휴대폰 10자리 → 01X-XXX-XXXX")
void mobile10() {
assertEquals("011-123-4567", PhoneNumberUtil.normalize("0111234567"));
}
@Test
@DisplayName("서울 02 → 9자리 02-XXX-XXXX / 10자리 02-XXXX-XXXX")
void seoul() {
assertEquals("02-123-4567", PhoneNumberUtil.normalize("021234567"));
assertEquals("02-1234-5678", PhoneNumberUtil.normalize("0212345678"));
assertEquals("02-1234-5678", PhoneNumberUtil.normalize("02-1234-5678"));
}
@Test
@DisplayName("지역/070 → 10자리 0XX-XXX-XXXX / 11자리 0XX-XXXX-XXXX")
void area() {
assertEquals("031-234-5678", PhoneNumberUtil.normalize("0312345678"));
assertEquals("070-1234-5678", PhoneNumberUtil.normalize("07012345678"));
}
@Test
@DisplayName("안심번호 050X 12자리 → 0XXX-XXXX-XXXX")
void safetyNumber() {
assertEquals("0507-1234-5678", PhoneNumberUtil.normalize("050712345678"));
}
@Test
@DisplayName("대표번호 15XX/16XX/18XX 8자리 → XXXX-XXXX")
void representative() {
assertEquals("1588-1234", PhoneNumberUtil.normalize("15881234"));
assertEquals("1644-5678", PhoneNumberUtil.normalize("16445678"));
assertEquals("1800-1234", PhoneNumberUtil.normalize("18001234"));
}
@Test
@DisplayName("미해당 패턴/null/blank 는 원본 유지")
void passthrough() {
assertEquals("123", PhoneNumberUtil.normalize("123"));
assertEquals("abc", PhoneNumberUtil.normalize("abc"));
assertEquals("", PhoneNumberUtil.normalize(""));
assertNull(PhoneNumberUtil.normalize(null));
}
@Test
@DisplayName("digitsOnly 는 숫자만 추출, null 유지")
void digitsOnly() {
assertEquals("01012345678", PhoneNumberUtil.digitsOnly("010-1234-5678"));
assertEquals("0212345678", PhoneNumberUtil.digitsOnly("02-1234-5678"));
assertNull(PhoneNumberUtil.digitsOnly(null));
}
}