10 Commits

Author SHA1 Message Date
Rinjae 5585df9133 메뉴 기능 추가 - menu.yml GNB 정의 - 접근 인터셉터/뷰 주입/내부 API 구현 2026-08-05 14:19:33 +09:00
Rinjae 2c63fa1557 개발자포탈 미게시 GW 인터페이스 표시 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- hiddenApiCount 필드로 미게시 인터페이스 집계 노출
- API Pills 및 관련 CSS/JS 수정
- 영향 인터페이스 노출 로직 개선 및 VisibleApis 클래스 적용
2026-08-05 10:44:50 +09:00
Rinjae d71af34950 공지 연계 기능 추가 및 UI 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 진행 중, 과거 장애/점검 카드에 공지 제목 및 본문 추가.
- 공지 본문 2열 레이아웃 및 세로 스크롤 스타일 적용.
- CSS 반응형 수정(768px → 1024px).
2026-08-05 09:33:22 +09:00
Rinjae fbc145d145 로고 워드마크 정렬 수정:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 모바일 로고 텍스트 및 링크 baseline 맞춤.
- CSS 수정 및 스타일 정렬 개선.
2026-08-04 19:27:54 +09:00
Rinjae f36b1a6478 Merge branch 'master' into design
# Conflicts:
#	src/main/resources/static/css/main.css.map
2026-08-04 19:23:45 +09:00
Rinjae 8f03209f8a UI 문구 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 공지 상세 화면 "끝" → "종료"로 수정
- Webhook 가이드 "장애 시작" → "장애 발생"으로 변경
2026-08-04 16:20:11 +09:00
hong 8743592fde fix : footer 수정 2026-08-04 14:59:49 +09:00
hong a0685c8689 merge 충돌 해결 2026-08-04 14:53:33 +09:00
hong c0ae60f738 fix : 부분적으로 수정 2026-08-04 14:50:27 +09:00
Rinjae b45a7a162a - IncidentTimelineUI VO 추가 - 장애 타임라인 화면 구현 지원
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- PortalNoticeManService 장애 상태 타임라인 로직 추가
- 메인 CSS에 타임라인 스타일 추가
2026-08-04 13:10:31 +09:00
75 changed files with 4423 additions and 3276 deletions
+1
View File
@@ -521,6 +521,7 @@ ls -lh src/main/resources/static/css/main.min.css # minified
## 문서
- **개발환경 준비 사항**: [`djb-docs/개발환경-준비-사항.md`](djb-docs/개발환경-준비-사항.md) — JDK·Gradle·Node.js·SASS 설치 가이드
- **메뉴 관리 개발 가이드**: [`readme-docs/메뉴-관리-개발-가이드.md`](readme-docs/메뉴-관리-개발-가이드.md) — menu.yml/roles.yml 스키마·시딩 규칙·캐시 리로드·admin 포탈메뉴관리 연동
- **프로젝트 상세 지침**: `CLAUDE.md` (한글)
- **사용자 가이드**: `개발자포탈.md` (한글)
- **빌드 스크립트**: `build-gf63.sh`, `deploy_portal.sh`
@@ -0,0 +1,85 @@
# 메뉴 관리 개발 가이드
포탈 GNB/마이페이지 메뉴는 `menu.yml` → DB(PTL_MENU_*) → 캐시 → 템플릿 렌더 구조로 동작하며,
노출/배치 관리는 eapim-admin **포탈메뉴관리**(파트너포탈 > 포탈관리 > 메뉴 관리)에서 수행한다.
## 구성 요소
| 구성 | 위치 | 역할 |
|---|---|---|
| `menu.yml` | `src/main/resources/menu.yml` | 기본 메뉴 정의 (id/노출명/path/권한/기본 배치) |
| `roles.yml` | `src/main/resources/roles.yml` | 역할 정의 (`portal.portal_security` 이동분) |
| 엔티티/공유 서비스 | `elink-portal-common` `com.eactive.apim.portal.menu.*` | PTL_MENU_ITEM·PTL_MENU_PLACEMENT·PTL_ROLE(+AUTHORITY), `PortalMenuDataService` |
| 시더 | `djb/menu/MenuSeeder.java` | 부팅 시 yml→DB 적재 (ApplicationReadyEvent) |
| 캐시 | `djb/menu/MenuService.java` | role 비의존 트리 스냅샷, TTL 1시간(PTL_PROPERTY) |
| 렌더 | `djb/menu/MenuModelAdvice.java` → 모델 `menuView` | 요청별 노출(EXPOSE_ROLES) 필터 |
| 접근 제어 | `djb/menu/MenuAccessInterceptor.java` | ACCESS_ROLES 서버측 집행 (경로 정확 일치) |
| 내부 API | `djb/menu/MenuInternalController.java` | `POST /internal/menu/reload` (admin 캐시 리로드 수신) |
메뉴를 소비하는 템플릿: `fragment/djbank/header_container.html`(데스크톱 nav·마이페이지 드롭다운·모바일 drawer),
`fragment/djbank/service_sidebar.html`. 모두 `${menuView}` 를 반복 렌더하므로 **메뉴 추가 시 템플릿 수정 불필요**.
## menu.yml 스키마
```yaml
portal-menu:
items:
- id: support # kebab-case 필수 (^[a-z0-9-]+$). 변경 금지(변경=신규 항목)
name: "고객지원"
group: true # 상위 그룹. path 생략 시 클릭 없음(자식 있어야 노출)
section: GNB # GNB(기본) | MYPAGE. 자식은 부모 섹션 상속
expose-roles: [] # 생략=전체(익명 포함), AUTHENTICATED=로그인자, 그 외 역할코드 any-of
children:
- { id: support-faq, name: "FAQ", path: /faq_list }
- { id: my-page-webhook, name: "Webhook 관리", path: /webhook, icon: fa-bell,
expose-roles: [ROLE_WEBHOOK], access-roles: [ROLE_WEBHOOK] }
```
- `expose-roles` = 메뉴 **노출** 조건, `access-roles` = URL **접근** 조건(인터셉터 차단, redirect).
- `icon` 은 마이페이지 드롭다운 전용(FontAwesome 클래스).
- 정렬은 yml 나열 순서(기본 배치 sort = index×10).
## 시딩 규칙 (MenuSeeder)
1. **항목**: id 기준 upsert. yml 값이 바뀌면 DFLT_*(기본값 스냅샷)를 갱신하고,
**관리자가 수정하지 않은 필드(현재값==구 기본값)만** 새 기본값을 따라간다.
구조 필드(`group`/`section`/`icon`/`new-window`)는 항상 yml 이 이긴다.
2. **배치**: `PTL_MENU_PLACEMENT`**비어있을 때만** 기본 배치로 최초 시딩.
이후 배치는 admin 이 소유한다 — 재배포/재기동에도 보존됨.
3. yml 에서 항목을 제거해도 DB 는 삭제하지 않고 경고 로그만 남긴다(수동 정리).
4. 부팅 시딩 주체는 인증 사용자가 없으므로 `CREATED_BY=SYSTEM`.
## 캐시와 리로드
- 스냅샷 TTL: PTL_PROPERTY `Portal / menu.cache.ttl-seconds` (기본 3600초).
- 즉시 반영: `curl -X POST http://127.0.0.1:39130/internal/menu/reload`
(admin 포탈메뉴관리의 [캐시 Reload] 버튼이 동일 호출 수행).
- 내부 API 가드: `Portal / menu.internal.allow-ips` 허용 IP 목록(기본 loopback)
+ X-Forwarded-For 동반 요청 거부. CSRF 면제(`/internal/menu/**`).
- admin 측 호출 URL: `Portal / portal.internal.menu-reload-url`.
## 새 메뉴 추가 절차
**기본 메뉴(코드 배포와 함께)**
1. 페이지/라우트 준비 (`portal.pages` 또는 `@GetMapping` — 기존 방식 그대로)
2. `menu.yml` 에 항목 추가 (필요 시 breadcrumb 용 `page.home` 트리도 갱신 — 별도 체계 유지)
3. 재기동 → 시딩 로그 확인 → 헤더/드로어 노출 확인
4. 이미 운영 중인 DB 라면 배치는 자동 추가되지 않음(배치 시딩은 최초 1회) —
admin 화면에서 미배치 → 원하는 위치로 드래그 후 저장
**운영자 임시 메뉴(외부 링크 등)**: admin 포탈메뉴관리 [메뉴 추가] → 미배치 생성 → 드래그 배치 → 저장 → 캐시 Reload.
커스텀 항목은 미배치 시 삭제된다.
## 로컬 개발 주의
- `gradle bootRun` 으로 시딩까지 확인하려면 damo-manager 가 classpath 에 필요:
`JAVA_TOOL_OPTIONS="-Xbootclasspath/a:<...>/apache-tomcat-9.0.115-djb/lib/damo-manager.jar"`
(미지정 시 감사 컬럼 암호화 컨버터에서 NoClassDefFoundError).
- 템플릿/메뉴 반영 확인은 서버 재시작 후 curl 로.
- elink-portal-common 수정 후 Q클래스 duplicate 컴파일 오류 시 각 모듈 `build/generated` 삭제 후 재컴파일.
## 역할(roles.yml) 변경
- 로그인 권한 확장은 `PortalRolesProperties`(yml 바인딩)를 직접 사용 — DB 미러(PTL_ROLE*)는
admin 권한 선택 체크박스 소스 전용.
- 역할 추가 시 `roles.yml``authority-names` 에 한글 라벨을 함께 등록해야 admin 화면에 표기된다.
@@ -78,7 +78,7 @@ public class AppServiceFacade {
List<AppRequest> appRequests = appRequestRepository.findAllByOrgAndTypeIsInAndApproval_ApprovalStatusIn(portalOrg, types,
Arrays.asList(new ProcessingState(), new RequestedState()));
// 승인정보(approval) 없는 신청도 목록에 노출`한다. (사용자가 직접 삭제 가능)
// 승인정보(approval) 없는 신청도 목록에 노출` 한다. (사용자가 직접 삭제 가능)
appRequests.addAll(appRequestRepository .findAllByOrgAndTypeIsInAndApprovalIsNull(portalOrg, types));
// 진행중(PROCESSING) → 요청됨(REQUESTED) → 승인정보 없음 순, 같은 상태끼리는 최근 신청 순
@@ -1,5 +1,6 @@
package com.eactive.apim.portal.apps.community.notice.dto;
import com.eactive.apim.portal.djb.apistatus.dto.TimelineEntryDTO;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@@ -53,6 +54,14 @@ public class PortalNoticeDTO {
private String state;
private String previousState;
private List<IncidentAffectedApiDTO> affectedApis = Collections.emptyList();
/** 개발자포탈에 게시되지 않아 개별 노출하지 않는 GW 인터페이스 건수 */
private int hiddenApiCount;
/** 장애 처리 타임라인. 최신순(내림차순), 공개(visibleYn='Y') 항목만 담는다. */
private List<TimelineEntryDTO> timeline = Collections.emptyList();
public boolean hasTimeline() {
return timeline != null && !timeline.isEmpty();
}
public boolean isIncidentType() {
return NOTICE_TYPE_INCIDENT.equals(noticeType);
@@ -2,11 +2,18 @@ package com.eactive.apim.portal.apps.community.notice.repository;
import com.eactive.apim.portal.portalNotice.entity.PortalNotice;
import com.eactive.eai.rms.data.EMSDataSource;
import java.util.Collection;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
@EMSDataSource
public interface PortalNoticeRepository extends JpaRepository<PortalNotice, String>, JpaSpecificationExecutor<PortalNotice> {
/**
* 게시 중인 공지만 골라 한 번에 읽는다. API Status 카드가 연결 공지 본문을 붙일 때 사용한다.
* 미게시(USE_YN='N')·삭제된 공지는 결과에서 자연히 빠진다.
*/
List<PortalNotice> findByIdInAndUseYn(Collection<String> ids, String useYn);
}
@@ -5,6 +5,9 @@ import com.eactive.apim.portal.apps.community.notice.dto.PortalNoticeDTO;
import com.eactive.apim.portal.apps.community.notice.dto.PortalNoticeSearch;
import com.eactive.apim.portal.apps.community.notice.mapper.PortalNoticeMapper;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncidentApi;
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusAssembler;
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusCatalogService;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentApiRepository;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentRepository;
import com.eactive.apim.portal.portalNotice.entity.PortalNotice;
@@ -16,8 +19,10 @@ import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
@@ -29,6 +34,8 @@ public class PortalNoticeFacadeImpl implements PortalNoticeFacade {
private final PortalNoticeMapper portalNoticeMapper;
private final DjbApistatusIncidentRepository incidentRepository;
private final DjbApistatusIncidentApiRepository incidentApiRepository;
private final ApiStatusAssembler apiStatusAssembler;
private final ApiStatusCatalogService apiStatusCatalogService;
@Override
public List<PortalNoticeDTO> getLatestNotices() {
@@ -67,11 +74,13 @@ public class PortalNoticeFacadeImpl implements PortalNoticeFacade {
private void populateIncident(PortalNoticeDTO dto) {
if (!dto.isIncidentOrMaintenance()) {
dto.setAffectedApis(Collections.emptyList());
dto.setTimeline(Collections.emptyList());
return;
}
Optional<DjbApistatusIncident> incidentOpt = incidentRepository.findByNoticeId(dto.getId());
if (!incidentOpt.isPresent()) {
dto.setAffectedApis(Collections.emptyList());
dto.setTimeline(Collections.emptyList());
return;
}
DjbApistatusIncident incident = incidentOpt.get();
@@ -81,10 +90,28 @@ public class PortalNoticeFacadeImpl implements PortalNoticeFacade {
dto.setState(incident.getState() == null ? null : incident.getState().name());
dto.setPreviousState(incident.getPreviousState() == null ? null : incident.getPreviousState().name());
List<IncidentAffectedApiDTO> apis = incidentApiRepository
.findByIncidentIdOrderByApiId(incident.getIncidentId()).stream()
.map(api -> new IncidentAffectedApiDTO(api.getApiId(), api.getApiName()))
.collect(Collectors.toList());
// 영향 인터페이스 중 개발자포탈에 게시된 API 만 개별 노출한다.
// 나머지 GW 인터페이스는 이름·ID 를 감추고 건수로만 알린다.
Map<String, String> visibleNames = apiStatusCatalogService.getVisibleApiNames();
List<IncidentAffectedApiDTO> apis = new ArrayList<>();
int hiddenCount = 0;
for (DjbApistatusIncidentApi api :
incidentApiRepository.findByIncidentIdOrderByApiId(incident.getIncidentId())) {
String publishedName = visibleNames.get(api.getApiId());
if (publishedName == null) {
hiddenCount++;
continue;
}
apis.add(new IncidentAffectedApiDTO(api.getApiId(), publishedName));
}
dto.setAffectedApis(apis);
dto.setHiddenApiCount(hiddenCount);
// 장애·지연만 타임라인을 붙인다 (점검은 타임라인을 쌓지 않음 — ADR-F15)
boolean degrading = incident.getKind() != null && incident.getKind().isDegrading();
dto.setTimeline(degrading
? apiStatusAssembler.loadTimelines(Collections.singletonList(incident.getIncidentId()))
.getOrDefault(incident.getIncidentId(), Collections.emptyList())
: Collections.emptyList());
}
}
@@ -10,7 +10,7 @@ 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.config.PortalProperties;
import com.eactive.apim.portal.djb.menu.PortalRolesProperties;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
@@ -50,7 +50,7 @@ public class PortalUserAuthService implements UserDetailsService {
private final PortalUserRepository portalUserRepository;
private final PortalUserMapper portalUserMapper;
private final PortalProperties portalProperties;
private final PortalRolesProperties portalRolesProperties;
private final PasswordEncoder passwordEncoder;
private final MessageHandlerService messageHandlerService;
private final MessageRequestRepository messageRequestRepository;
@@ -76,7 +76,7 @@ public class PortalUserAuthService implements UserDetailsService {
*/
public PortalAuthenticatedUser buildAuthenticatedUser(PortalUser portalUser) {
RoleCode userRole = portalUser.getRoleCode() == null ? RoleCode.ROLE_USER : portalUser.getRoleCode();
List<String> roles = portalProperties.getPortalSecurity().get(userRole);
List<String> roles = portalRolesProperties.getAuthorities(userRole);
PortalAuthenticatedUser authenticatedUser = portalUserMapper.portalUserToAuthenticatedUser(portalUser);
authenticatedUser.getAuthorities().add(new SimpleGrantedAuthority(userRole.name()));
@@ -109,6 +109,7 @@ public class PortalConfigSecurity {
.csrfTokenRepository(csrfTokenRepository)
.ignoringRequestMatchers(new AntPathRequestMatcher("/_proxy/**/*"))
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/migration/**"))
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/menu/**"))
)
// 로그인 페이지에 오래 머물러 세션(=CSRF 토큰 저장소)이 타임아웃되면
// 로그인 제출 시 CsrfFilter가 AnonymousAuthenticationFilter보다 먼저 예외를 던져
@@ -44,6 +44,7 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
private final Environment environment;
private final com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService twoFactorService;
private final com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties twoFactorProperties;
private final com.eactive.apim.portal.djb.menu.MenuService menuService;
// 정적자원 해시 버전닝 토글(application.yml: app.resource-versioning.enabled).
// prod 는 이 값을 무시하고 항상 ON 으로 동작한다(isResourceVersioningEnabled 참고).
@@ -58,10 +59,12 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
public PortalConfigWebDispatcherServlet(Environment environment,
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService twoFactorService,
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties twoFactorProperties) {
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties twoFactorProperties,
com.eactive.apim.portal.djb.menu.MenuService menuService) {
this.environment = environment;
this.twoFactorService = twoFactorService;
this.twoFactorProperties = twoFactorProperties;
this.menuService = menuService;
}
@@ -131,6 +134,14 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
.addPathPatterns("/**")
.excludePathPatterns(staticExcludes)
.excludePathPatterns("/auth/2fa/**");
// 메뉴 접근 권한(ACCESS_ROLES) 가드. 메뉴 경로 정확 일치 시에만 검사하며
// 하위 경로는 기존 @Secured / PageRoute.role 안전망에 위임한다.
registry.addInterceptor(new com.eactive.apim.portal.djb.menu.MenuAccessInterceptor(menuService))
.addPathPatterns("/**")
.excludePathPatterns(staticExcludes)
.excludePathPatterns("/internal/**", "/auth/2fa/**",
"/login", "/actionLogin.do", "/actionLogout.do", "/error");
}
@Bean
@@ -1,13 +1,11 @@
package com.eactive.apim.portal.config;
import com.eactive.apim.portal.common.pagerouter.property.PageRoute;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
@Data
@Component
@@ -25,8 +23,6 @@ public class PortalProperties {
private String authVirtualCode = "";
private Map<RoleCode, List<String>> portalSecurity;
private FileProperties file = new FileProperties();
/**
@@ -15,6 +15,10 @@ public class ActiveIncidentDTO {
private Long incidentId;
private String noticeId;
/** 연결된 공지 제목. 게시 중인 공지가 없으면 null */
private String noticeSubject;
/** 연결된 공지 본문(HTML). 게시 중인 공지가 없으면 null */
private String noticeDetail;
private String kind;
private String state;
private String stateLabel;
@@ -24,5 +28,7 @@ public class ActiveIncidentDTO {
private LocalDateTime startedAt;
private long elapsedMinutes;
private List<AffectedApiDTO> apis = new ArrayList<>();
/** 개발자포탈에 게시되지 않아 개별 노출하지 않는 GW 인터페이스 건수 */
private int hiddenApiCount;
private List<TimelineEntryDTO> recentTimeline = new ArrayList<>();
}
@@ -15,6 +15,10 @@ public class MaintenanceCardDTO {
private Long incidentId;
private String noticeId;
/** 연결된 공지 제목. 게시 중인 공지가 없으면 null */
private String noticeSubject;
/** 연결된 공지 본문(HTML). 게시 중인 공지가 없으면 null */
private String noticeDetail;
private String title;
private String summary;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
@@ -23,6 +27,8 @@ public class MaintenanceCardDTO {
private LocalDateTime endAt;
private Long durationMinutes;
private List<AffectedApiDTO> impactedApis = new ArrayList<>();
/** 개발자포탈에 게시되지 않아 개별 노출하지 않는 GW 인터페이스 건수 */
private int hiddenApiCount;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime registeredAt;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
@@ -16,6 +16,10 @@ public class PastIssueCardDTO {
private Long incidentId;
private String noticeId;
/** 연결된 공지 제목. 게시 중인 공지가 없으면 null */
private String noticeSubject;
/** 연결된 공지 본문(HTML). 게시 중인 공지가 없으면 null */
private String noticeDetail;
private String kind;
private String state;
private String stateLabel;
@@ -29,5 +33,7 @@ public class PastIssueCardDTO {
private LocalDate dateGroup;
private Long durationMinutes;
private List<AffectedApiDTO> impactedApis = new ArrayList<>();
/** 개발자포탈에 게시되지 않아 개별 노출하지 않는 GW 인터페이스 건수 */
private int hiddenApiCount;
private List<TimelineEntryDTO> timeline = new ArrayList<>();
}
@@ -11,6 +11,9 @@ import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusInciden
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentApiRepository;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentTimelineRepository;
import com.eactive.apim.portal.apps.community.notice.repository.PortalNoticeRepository;
import com.eactive.apim.portal.portalNotice.entity.PortalNotice;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
@@ -37,6 +40,42 @@ public class ApiStatusAssembler {
private final DjbApistatusIncidentApiRepository incidentApiRepository;
private final DjbApistatusIncidentTimelineRepository timelineRepository;
private final PortalNoticeRepository portalNoticeRepository;
private final ApiStatusCatalogService catalogService;
/**
* 영향 인터페이스를 "개발자포탈에 게시된 API" 와 그 외 GW 인터페이스로 가른 결과.
* 게시된 것만 개별 노출하고 나머지는 건수로만 알린다.
*/
@Getter
@RequiredArgsConstructor
public static class VisibleApis {
private final List<AffectedApiDTO> visible;
private final int hiddenCount;
}
/**
* 이슈 영향 인터페이스에서 현재 사용자에게 공개된 API 만 남긴다.
*
* <p>이름은 이슈에 캐시된 EAI 서비스명이 아니라 포털 노출명(PTL_API_SPEC_INFO)을 쓴다 -
* 같은 API 가 화면마다 다른 이름으로 보이지 않게 한다.</p>
*/
private VisibleApis splitByVisibility(List<AffectedApiDTO> apis, Map<String, String> visibleNames) {
if (apis == null || apis.isEmpty()) {
return new VisibleApis(Collections.emptyList(), 0);
}
List<AffectedApiDTO> visible = new ArrayList<>();
int hidden = 0;
for (AffectedApiDTO api : apis) {
String publishedName = visibleNames.get(api.getApiId());
if (publishedName == null) {
hidden++;
continue;
}
visible.add(new AffectedApiDTO(api.getApiId(), publishedName, api.getRecoveredAt()));
}
return new VisibleApis(visible, hidden);
}
public Map<Long, List<AffectedApiDTO>> loadApis(Collection<Long> incidentIds) {
if (incidentIds == null || incidentIds.isEmpty()) {
@@ -69,6 +108,30 @@ public class ApiStatusAssembler {
return result;
}
/**
* 이슈에 연결된 공지. 게시 중(USE_YN='Y')인 것만 담는다.
*
* <p>조회 자체의 공개 조건({@code ApiStatusIncidentQueryRepository.VISIBLE})과 같은 기준이라
* 여기서 빠지는 건은 공지가 삭제된 고아 행뿐이다.</p>
*/
public Map<String, PortalNotice> loadNotices(Collection<String> noticeIds) {
if (noticeIds == null || noticeIds.isEmpty()) {
return Collections.emptyMap();
}
List<String> ids = noticeIds.stream()
.filter(StringUtils::isNotBlank)
.distinct()
.collect(Collectors.toList());
if (ids.isEmpty()) {
return Collections.emptyMap();
}
Map<String, PortalNotice> result = new HashMap<>();
for (PortalNotice notice : portalNoticeRepository.findByIdInAndUseYn(ids, "Y")) {
result.put(notice.getId(), notice);
}
return result;
}
public List<ActiveIncidentDTO> toActiveIncidents(List<DjbApistatusIncident> incidents, LocalDateTime now) {
if (incidents.isEmpty()) {
return Collections.emptyList();
@@ -76,6 +139,10 @@ public class ApiStatusAssembler {
List<Long> ids = incidentIds(incidents);
Map<Long, List<AffectedApiDTO>> apis = loadApis(ids);
Map<Long, List<TimelineEntryDTO>> timelines = loadTimelines(ids);
Map<String, String> visibleNames = catalogService.getVisibleApiNames();
Map<String, PortalNotice> notices = loadNotices(incidents.stream()
.map(DjbApistatusIncident::getNoticeId)
.collect(Collectors.toList()));
List<ActiveIncidentDTO> result = new ArrayList<>();
for (DjbApistatusIncident incident : incidents) {
@@ -89,7 +156,16 @@ public class ApiStatusAssembler {
dto.setSummary(incident.getSummary());
dto.setStartedAt(incident.getStartedAt());
dto.setElapsedMinutes(ApiStatusSupport.minutesBetween(incident.getStartedAt(), now));
dto.setApis(apis.getOrDefault(incident.getIncidentId(), Collections.emptyList()));
VisibleApis affected = splitByVisibility(
apis.getOrDefault(incident.getIncidentId(), Collections.emptyList()), visibleNames);
dto.setApis(affected.getVisible());
dto.setHiddenApiCount(affected.getHiddenCount());
PortalNotice notice = incident.getNoticeId() == null ? null : notices.get(incident.getNoticeId());
if (notice != null) {
dto.setNoticeSubject(notice.getNoticeSubject());
dto.setNoticeDetail(notice.getNoticeDetail());
}
List<TimelineEntryDTO> all = timelines.getOrDefault(incident.getIncidentId(), Collections.emptyList());
dto.setRecentTimeline(all.size() > RECENT_TIMELINE_SIZE
@@ -104,19 +180,33 @@ public class ApiStatusAssembler {
return Collections.emptyList();
}
Map<Long, List<AffectedApiDTO>> apis = loadApis(incidentIds(incidents));
Map<String, String> visibleNames = catalogService.getVisibleApiNames();
Map<String, PortalNotice> notices = loadNotices(incidents.stream()
.map(DjbApistatusIncident::getNoticeId)
.collect(Collectors.toList()));
List<MaintenanceCardDTO> result = new ArrayList<>();
for (DjbApistatusIncident incident : incidents) {
MaintenanceCardDTO dto = new MaintenanceCardDTO();
dto.setIncidentId(incident.getIncidentId());
dto.setNoticeId(incident.getNoticeId());
PortalNotice notice = incident.getNoticeId() == null ? null : notices.get(incident.getNoticeId());
if (notice != null) {
dto.setNoticeSubject(notice.getNoticeSubject());
dto.setNoticeDetail(notice.getNoticeDetail());
}
dto.setTitle(incident.getTitle());
dto.setSummary(incident.getSummary());
dto.setStartedAt(incident.getStartedAt());
dto.setEndAt(incident.getEndAt());
dto.setDurationMinutes(incident.getEndAt() == null ? null
: ApiStatusSupport.minutesBetween(incident.getStartedAt(), incident.getEndAt()));
dto.setImpactedApis(apis.getOrDefault(incident.getIncidentId(), Collections.emptyList()));
VisibleApis affected = splitByVisibility(
apis.getOrDefault(incident.getIncidentId(), Collections.emptyList()), visibleNames);
dto.setImpactedApis(affected.getVisible());
dto.setHiddenApiCount(affected.getHiddenCount());
dto.setRegisteredAt(incident.getCreatedDate());
dto.setLastModifiedAt(incident.getLastModifiedDate());
result.add(dto);
@@ -133,18 +223,29 @@ public class ApiStatusAssembler {
}
List<Long> ids = incidentIds(incidents);
Map<Long, List<AffectedApiDTO>> apis = loadApis(ids);
Map<String, String> visibleNames = catalogService.getVisibleApiNames();
List<Long> incidentKindIds = incidents.stream()
.filter(incident -> incident.getKind() != null && incident.getKind().isDegrading())
.map(DjbApistatusIncident::getIncidentId)
.collect(Collectors.toList());
Map<Long, List<TimelineEntryDTO>> timelines = loadTimelines(incidentKindIds);
Map<String, PortalNotice> notices = loadNotices(incidents.stream()
.map(DjbApistatusIncident::getNoticeId)
.collect(Collectors.toList()));
List<PastIssueCardDTO> result = new ArrayList<>();
for (DjbApistatusIncident incident : incidents) {
PastIssueCardDTO dto = new PastIssueCardDTO();
dto.setIncidentId(incident.getIncidentId());
dto.setNoticeId(incident.getNoticeId());
PortalNotice notice = incident.getNoticeId() == null ? null : notices.get(incident.getNoticeId());
if (notice != null) {
dto.setNoticeSubject(notice.getNoticeSubject());
dto.setNoticeDetail(notice.getNoticeDetail());
}
dto.setKind(incident.getKind() == null ? null : incident.getKind().name());
dto.setState(incident.getState() == null ? null : incident.getState().name());
dto.setStateLabel(ApiStatusSupport.stateLabel(incident.getState()));
@@ -155,7 +256,10 @@ public class ApiStatusAssembler {
dto.setDateGroup(incident.getStartedAt() == null ? null : incident.getStartedAt().toLocalDate());
dto.setDurationMinutes(incident.getEndAt() == null ? null
: ApiStatusSupport.minutesBetween(incident.getStartedAt(), incident.getEndAt()));
dto.setImpactedApis(apis.getOrDefault(incident.getIncidentId(), Collections.emptyList()));
VisibleApis affected = splitByVisibility(
apis.getOrDefault(incident.getIncidentId(), Collections.emptyList()), visibleNames);
dto.setImpactedApis(affected.getVisible());
dto.setHiddenApiCount(affected.getHiddenCount());
dto.setTimeline(timelines.getOrDefault(incident.getIncidentId(), Collections.emptyList()));
result.add(dto);
}
@@ -18,7 +18,9 @@ import java.time.Instant;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
@@ -80,6 +82,23 @@ public class ApiStatusCatalogService {
.collect(Collectors.toList());
}
/**
* 현재 사용자에게 공개된 API 의 {API ID → 노출명} 맵.
*
* <p>GW 인터페이스는 전부 이슈 데이터(DJB_APISTATUS_INCIDENT_API)에 들어오지만
* 개발자포탈에 게시되는 것은 그 일부(= API)뿐이다. 화면은 이 맵에 있는 것만
* 개별 이름으로 노출하고 나머지는 건수로 묶는다.</p>
*
* <p>{@link #getSelectableApis()} 와 같은 경로라 역할·소속에 따른 공개 범위가 그대로 반영된다.</p>
*/
public Map<String, String> getVisibleApiNames() {
Map<String, String> names = new LinkedHashMap<>();
for (ApiOptionDTO api : getSelectableApis()) {
names.put(api.getApiId(), api.getApiName());
}
return names;
}
/**
* API 상태 모니터링 Job(eapim-admin Quartz)의 마지막 실행 시각.
*
@@ -0,0 +1,70 @@
package com.eactive.apim.portal.djb.menu;
import com.eactive.apim.portal.menu.entity.PortalMenuItem;
import com.eactive.apim.portal.common.util.SecurityUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.List;
/**
* 메뉴 접근 권한(ACCESS_ROLES) 서버측 집행.
*
* <p>요청 경로가 접근 권한이 설정된 메뉴 경로와 정확히 일치할 때만 검사한다
* (하위 경로는 기존 안전망 @Secured / PageRoute.role 에 위임 — 사용자 결정).
* 미충족 시 익명은 로그인으로, 인증 사용자는 홈으로 리다이렉트한다.
* 메뉴에 등록되지 않은 경로는 통과시킨다.
*/
@Slf4j
@RequiredArgsConstructor
public class MenuAccessInterceptor implements HandlerInterceptor {
private final MenuService menuService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
String path = currentPath(request);
List<String> requiredRoles = menuService.getSnapshot().getAccessRolesByPath().get(path);
if (requiredRoles == null || requiredRoles.isEmpty()) {
return true;
}
if (isAllowed(requiredRoles)) {
return true;
}
if (!SecurityUtil.isAuthenticated()) {
response.sendRedirect(request.getContextPath() + "/login");
} else {
log.info("메뉴 접근 권한 미충족 - path: {}, 필요 권한: {}, 사용자: {}",
path, requiredRoles, SecurityUtil.getCurrentLoginId());
response.sendRedirect(request.getContextPath() + "/");
}
return false;
}
private boolean isAllowed(List<String> requiredRoles) {
for (String role : requiredRoles) {
if (PortalMenuItem.ROLE_AUTHENTICATED.equals(role)) {
if (SecurityUtil.isAuthenticated()) {
return true;
}
} else if (SecurityUtil.hasRole(role)) {
return true;
}
}
return false;
}
private String currentPath(HttpServletRequest request) {
String uri = request.getRequestURI();
String contextPath = request.getContextPath();
if (contextPath != null && !contextPath.isEmpty() && uri.startsWith(contextPath)) {
uri = uri.substring(contextPath.length());
}
return uri;
}
}
@@ -0,0 +1,99 @@
package com.eactive.apim.portal.djb.menu;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 메뉴 캐시 내부 API — eapim-admin 의 reload 명령 수신용.
*
* <p>가드: PTL_PROPERTY {@code Portal / menu.internal.allow-ips} 허용 IP 목록
* (기본 loopback) + X-Forwarded-For 동반 요청 거부
* ({@code LegacyEncryptionMigrationController.assertLocalOnly} 모델).
* CSRF 는 PortalConfigSecurity 에서 {@code /internal/menu/**} 예외 처리.</p>
*
* <pre>curl -X POST http://127.0.0.1:39130/internal/menu/reload</pre>
*/
@Slf4j
@RestController
@RequestMapping("/internal/menu")
@RequiredArgsConstructor
public class MenuInternalController {
static final String PROP_GROUP = "Portal";
static final String PROP_ALLOW_IPS = "menu.internal.allow-ips";
static final String DEFAULT_ALLOW_IPS = "127.0.0.1,::1";
private final MenuService menuService;
private final PortalPropertyService portalPropertyService;
@PostMapping("/reload")
public ResponseEntity<Map<String, Object>> reload(HttpServletRequest request) {
if (!isAllowed(request)) {
Map<String, Object> denied = new LinkedHashMap<>();
denied.put("result", "DENIED");
denied.put("message", "허용되지 않은 접근입니다. (menu.internal.allow-ips 확인)");
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(denied);
}
MenuService.MenuSnapshot snapshot = menuService.reload();
log.info("메뉴 캐시 리로드 명령 수신 - from: {}", request.getRemoteAddr());
Map<String, Object> body = new LinkedHashMap<>();
body.put("result", "OK");
body.put("itemCount", snapshot.getItemCount());
body.put("reloadedAt", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
return ResponseEntity.ok(body);
}
/**
* 허용 IP 검사. 프록시 경유(X-Forwarded-For 존재) 요청은 IP 신뢰 불가로 거부한다.
* (embedded Tomcat 은 forward-headers-strategy: native 로 XFF 가 remoteAddr 에 반영될 수 있으나
* 그 경우에도 allowlist 검사로 차단된다. WebLogic WAR 배포에서는 원 소켓 IP 로 검사된다.)
*/
private boolean isAllowed(HttpServletRequest request) {
String remote = canonicalize(request.getRemoteAddr());
boolean viaProxy = request.getHeader("X-Forwarded-For") != null;
Set<String> allowed = Arrays.stream(resolveAllowIps().split(","))
.map(String::trim)
.filter(ip -> !ip.isEmpty())
.map(MenuInternalController::canonicalize)
.collect(Collectors.toSet());
if (viaProxy || !allowed.contains(remote)) {
log.warn("메뉴 내부 API 차단 - remote: {}, viaProxy: {}", remote, viaProxy);
return false;
}
return true;
}
private String resolveAllowIps() {
try {
return portalPropertyService.getOrCreateProperty(PROP_GROUP, PROP_ALLOW_IPS,
DEFAULT_ALLOW_IPS, "메뉴 내부 API(리로드) 허용 IP 목록(콤마 구분)");
} catch (Exception e) {
log.warn("허용 IP 목록 조회 실패 - 기본값({}) 사용", DEFAULT_ALLOW_IPS, e);
return DEFAULT_ALLOW_IPS;
}
}
/** IPv6 loopback 표기 통일 */
private static String canonicalize(String ip) {
return "0:0:0:0:0:0:0:1".equals(ip) ? "::1" : ip;
}
}
@@ -0,0 +1,94 @@
package com.eactive.apim.portal.djb.menu;
import com.eactive.apim.portal.menu.entity.PortalMenuItem;
import com.eactive.apim.portal.common.util.SecurityUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ModelAttribute;
import javax.servlet.http.HttpServletRequest;
import java.util.ArrayList;
import java.util.List;
/**
* 모든 뷰 요청에 role 필터가 적용된 {@code menuView} 를 주입한다
* (breadcrumb 의 GlobalControllerAdvice 와 동일한 방식).
*
* <p>노출 판정: EXPOSE_ROLES 비어있음(전체) ∥ AUTHENTICATED(로그인) ∥
* 역할 any-of. 경로 없는 그룹은 노출 자식이 하나도 없으면 제외한다.
*/
@Slf4j
@ControllerAdvice
@RequiredArgsConstructor
public class MenuModelAdvice {
private final MenuService menuService;
@ModelAttribute("menuView")
public MenuView menuView(HttpServletRequest request) {
try {
MenuService.MenuSnapshot snapshot = menuService.getSnapshot();
boolean authenticated = SecurityUtil.isAuthenticated();
List<MenuNode> gnb = new ArrayList<>();
MenuNode mypage = null;
for (MenuNode root : snapshot.getRoots()) {
MenuNode filtered = filter(root, authenticated);
if (filtered == null) {
continue;
}
if (PortalMenuItem.SECTION_MYPAGE.equals(filtered.getSection())) {
if (mypage == null) {
mypage = filtered;
}
} else {
gnb.add(filtered);
}
}
return new MenuView(gnb, mypage, request.getRequestURI());
} catch (Exception e) {
// 메뉴 조회 실패가 화면 전체를 막지 않도록 빈 메뉴로 폴백
log.error("메뉴 뷰 구성 실패 - 빈 메뉴로 렌더링합니다.", e);
return MenuView.empty();
}
}
private MenuNode filter(MenuNode node, boolean authenticated) {
if (!isExposed(node, authenticated)) {
return null;
}
MenuNode copy = node.copyWithoutChildren();
List<MenuNode> children = new ArrayList<>();
for (MenuNode child : node.getChildren()) {
MenuNode filteredChild = filter(child, authenticated);
if (filteredChild != null) {
children.add(filteredChild);
}
}
copy.setChildren(children);
// 경로 없는 그룹은 노출할 자식이 없으면 통째로 제외
if (copy.isGroup() && !copy.hasPath() && children.isEmpty()) {
return null;
}
return copy;
}
private boolean isExposed(MenuNode node, boolean authenticated) {
List<String> exposeRoles = node.getExposeRoles();
if (exposeRoles.isEmpty()) {
return true;
}
for (String role : exposeRoles) {
if (PortalMenuItem.ROLE_AUTHENTICATED.equals(role)) {
if (authenticated) {
return true;
}
} else if (SecurityUtil.hasRole(role)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,46 @@
package com.eactive.apim.portal.djb.menu;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 렌더용 메뉴 노드. {@link MenuService} 스냅샷 트리와
* {@link MenuModelAdvice} 의 요청별 필터 사본 양쪽에 사용한다.
*/
@Data
public class MenuNode {
private String menuId;
private String name;
/** 이동 경로 (null = 클릭 없는 그룹) */
private String path;
private boolean group;
/** GNB | MYPAGE */
private String section;
private String icon;
private boolean newWindow;
private List<String> exposeRoles = new ArrayList<>();
private List<String> accessRoles = new ArrayList<>();
private List<MenuNode> children = new ArrayList<>();
/** 필터 사본 생성 (children 제외 필드 복사) */
public MenuNode copyWithoutChildren() {
MenuNode copy = new MenuNode();
copy.menuId = menuId;
copy.name = name;
copy.path = path;
copy.group = group;
copy.section = section;
copy.icon = icon;
copy.newWindow = newWindow;
copy.exposeRoles = exposeRoles;
copy.accessRoles = accessRoles;
return copy;
}
public boolean hasPath() {
return path != null && !path.isEmpty();
}
}
@@ -0,0 +1,360 @@
package com.eactive.apim.portal.djb.menu;
import com.eactive.apim.portal.menu.entity.PortalMenuItem;
import com.eactive.apim.portal.menu.entity.PortalMenuPlacement;
import com.eactive.apim.portal.menu.entity.PortalRole;
import com.eactive.apim.portal.menu.entity.PortalRoleAuthority;
import com.eactive.apim.portal.menu.entity.PortalRoleAuthorityId;
import com.eactive.apim.portal.menu.repository.PortalMenuItemRepository;
import com.eactive.apim.portal.menu.repository.PortalMenuPlacementRepository;
import com.eactive.apim.portal.menu.repository.PortalRoleAuthorityRepository;
import com.eactive.apim.portal.menu.repository.PortalRoleRepository;
import com.eactive.apim.portal.menu.service.PortalMenuDataService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.BiConsumer;
import java.util.function.Function;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
* 부팅 시 menu.yml / roles.yml → DB 적재.
*
* <ul>
* <li>역할: PTL_ROLE upsert + PTL_ROLE_AUTHORITY 재구축 (roles.yml 미러)</li>
* <li>메뉴 항목: id 기준 upsert — yml 기본값이 바뀌면 DFLT_* 를 갱신하고,
* 관리자가 수정하지 않은(현재값==구 기본값) 필드만 새 기본값을 따라간다</li>
* <li>배치: PTL_MENU_PLACEMENT 가 비어있을 때만 DFLT_* 로 최초 시딩</li>
* <li>yml 에서 사라진 PORTAL 항목은 경고 로그만 남기고 삭제하지 않는다</li>
* </ul>
*
* 부팅 컨텍스트에는 인증 주체가 없어 감사(@CreatedBy)가 비므로,
* 신규 행은 {@code createdBy=SYSTEM} 을 명시 세팅한다
* (AuditingHandler 는 auditor 부재 시 기존 값을 보존).
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class MenuSeeder implements ApplicationListener<ApplicationReadyEvent> {
static final String SYSTEM_AUDITOR = "SYSTEM";
private static final Pattern MENU_ID_PATTERN = Pattern.compile("^[a-z0-9-]+$");
private final PortalMenuYmlProperties menuYmlProperties;
private final PortalRolesProperties rolesProperties;
private final PortalMenuItemRepository menuItemRepository;
private final PortalMenuPlacementRepository menuPlacementRepository;
private final PortalRoleRepository roleRepository;
private final PortalRoleAuthorityRepository roleAuthorityRepository;
private final PortalMenuDataService menuDataService;
private final MenuService menuService;
@Override
@Transactional
public void onApplicationEvent(ApplicationReadyEvent event) {
try {
seedRoles();
seedMenuItems();
seedPlacementsIfEmpty();
menuService.reload();
} catch (Exception e) {
// 시딩 실패가 기동 자체를 막지 않도록 로그만 남긴다 (메뉴는 기존 DB 값으로 동작)
log.error("메뉴/역할 시딩 실패 - 기존 DB 데이터로 동작합니다.", e);
}
}
// ─────────────── 역할 ───────────────
private void seedRoles() {
int sortOrder = 10;
int upserted = 0;
Set<String> definedCodes = new HashSet<>();
for (PortalRolesProperties.RoleDef roleDef : rolesProperties.getRoles()) {
upserted += upsertRole(roleDef.getCode(), roleDef.getName(), PortalRole.TYPE_BASE, sortOrder) ? 1 : 0;
definedCodes.add(roleDef.getCode());
sortOrder += 10;
}
for (Map.Entry<String, String> entry : rolesProperties.getAuthorityNames().entrySet()) {
if (definedCodes.contains(entry.getKey())) {
continue;
}
upserted += upsertRole(entry.getKey(), entry.getValue(), PortalRole.TYPE_AUTHORITY, sortOrder) ? 1 : 0;
sortOrder += 10;
}
// 역할→권한 매핑은 순수 미러 — 통째로 재구축
roleAuthorityRepository.deleteAllInBatch();
roleAuthorityRepository.flush();
List<PortalRoleAuthority> mappings = rolesProperties.getRoles().stream()
.flatMap(roleDef -> roleDef.getAuthorities().stream()
.map(authority -> {
PortalRoleAuthority mapping = new PortalRoleAuthority();
mapping.setId(new PortalRoleAuthorityId(roleDef.getCode(), authority));
mapping.setCreatedBy(SYSTEM_AUDITOR);
return mapping;
}))
.collect(Collectors.toList());
roleAuthorityRepository.saveAll(mappings);
log.info("역할 시딩 완료 - PTL_ROLE upsert {}건, PTL_ROLE_AUTHORITY {}건 재구축", upserted, mappings.size());
}
private boolean upsertRole(String code, String name, String type, int sortOrder) {
PortalRole role = roleRepository.findById(code).orElse(null);
if (role == null) {
role = new PortalRole();
role.setRoleCode(code);
role.setCreatedBy(SYSTEM_AUDITOR);
} else if (Objects.equals(role.getRoleName(), name)
&& Objects.equals(role.getRoleType(), type)
&& Objects.equals(role.getSortOrder(), sortOrder)) {
return false;
}
role.setRoleName(name);
role.setRoleType(type);
role.setSortOrder(sortOrder);
roleRepository.save(role);
return true;
}
// ─────────────── 메뉴 항목 ───────────────
private void seedMenuItems() {
List<FlatMenuDef> flatDefs = flatten(menuYmlProperties.getItems());
validate(flatDefs);
int inserted = 0;
int updated = 0;
for (FlatMenuDef def : flatDefs) {
PortalMenuItem existing = menuItemRepository.findById(def.id).orElse(null);
if (existing == null) {
menuItemRepository.save(newItem(def));
inserted++;
} else if (updateItem(existing, def)) {
menuItemRepository.save(existing);
updated++;
}
}
// yml 에서 사라진 PORTAL 항목 경고 (자동 삭제 금지)
Set<String> ymlIds = flatDefs.stream().map(def -> def.id).collect(Collectors.toSet());
menuItemRepository.findAllBySourceType(PortalMenuItem.SOURCE_PORTAL).stream()
.map(PortalMenuItem::getMenuId)
.filter(id -> !ymlIds.contains(id))
.forEach(id -> log.warn("menu.yml 에 없는 기본 메뉴 항목이 DB에 남아 있습니다 (수동 정리 필요): {}", id));
log.info("메뉴 항목 시딩 완료 - 신규 {}건, 갱신 {}건, 유지 {}건",
inserted, updated, flatDefs.size() - inserted - updated);
}
private PortalMenuItem newItem(FlatMenuDef def) {
PortalMenuItem item = new PortalMenuItem();
item.setMenuId(def.id);
item.setSourceType(PortalMenuItem.SOURCE_PORTAL);
item.setCreatedBy(SYSTEM_AUDITOR);
item.setMenuName(def.name());
item.setMenuPath(def.path());
item.setExposeRoles(def.exposeCsv());
item.setAccessRoles(def.accessCsv());
applyStructure(item, def);
applyDefaults(item, def);
return item;
}
/**
* 기존 항목 upsert 갱신. 내용 필드는 "관리자 미수정(현재값==구 기본값) 시에만"
* 새 기본값을 따라가고, 구조 필드(group/section/icon/new-window)와 DFLT_* 는
* 항상 yml 값으로 맞춘다.
*
* @return 변경이 있었으면 true
*/
private boolean updateItem(PortalMenuItem item, FlatMenuDef def) {
boolean[] changed = {false};
followDefault(item, changed, def.name(), PortalMenuItem::getDfltMenuName,
PortalMenuItem::getMenuName, PortalMenuItem::setMenuName, PortalMenuItem::setDfltMenuName);
followDefault(item, changed, def.path(), PortalMenuItem::getDfltMenuPath,
PortalMenuItem::getMenuPath, PortalMenuItem::setMenuPath, PortalMenuItem::setDfltMenuPath);
followDefault(item, changed, def.exposeCsv(), PortalMenuItem::getDfltExposeRoles,
PortalMenuItem::getExposeRoles, PortalMenuItem::setExposeRoles, PortalMenuItem::setDfltExposeRoles);
followDefault(item, changed, def.accessCsv(), PortalMenuItem::getDfltAccessRoles,
PortalMenuItem::getAccessRoles, PortalMenuItem::setAccessRoles, PortalMenuItem::setDfltAccessRoles);
String groupYn = def.source.isGroup() ? "Y" : "N";
String section = def.section();
String newWindowYn = def.source.isNewWindow() ? "Y" : "N";
if (!Objects.equals(item.getGroupYn(), groupYn)
|| !Objects.equals(item.getMenuSection(), section)
|| !Objects.equals(item.getIconClass(), def.source.getIcon())
|| !Objects.equals(item.getNewWindowYn(), newWindowYn)) {
applyStructure(item, def);
changed[0] = true;
}
if (!Objects.equals(item.getDfltParentId(), def.parentId)
|| !Objects.equals(item.getDfltSortOrder(), def.sortOrder)) {
item.setDfltParentId(def.parentId);
item.setDfltSortOrder(def.sortOrder);
changed[0] = true;
}
return changed[0];
}
/**
* 내용 필드 1개에 대한 기본값 추종 처리: yml 기본값이 바뀌었을 때
* 현재값이 구 기본값과 같으면(관리자 미수정) 현재값도 새 기본값으로 갱신한다.
*/
private void followDefault(PortalMenuItem item, boolean[] changed, String newDefault,
Function<PortalMenuItem, String> defaultGetter,
Function<PortalMenuItem, String> currentGetter,
BiConsumer<PortalMenuItem, String> currentSetter,
BiConsumer<PortalMenuItem, String> defaultSetter) {
String oldDefault = defaultGetter.apply(item);
if (Objects.equals(oldDefault, newDefault)) {
return;
}
if (Objects.equals(currentGetter.apply(item), oldDefault)) {
currentSetter.accept(item, newDefault);
}
defaultSetter.accept(item, newDefault);
changed[0] = true;
}
private void applyStructure(PortalMenuItem item, FlatMenuDef def) {
item.setGroupYn(def.source.isGroup() ? "Y" : "N");
item.setMenuSection(def.section());
item.setIconClass(def.source.getIcon());
item.setNewWindowYn(def.source.isNewWindow() ? "Y" : "N");
}
private void applyDefaults(PortalMenuItem item, FlatMenuDef def) {
item.setDfltMenuName(def.name());
item.setDfltMenuPath(def.path());
item.setDfltExposeRoles(def.exposeCsv());
item.setDfltAccessRoles(def.accessCsv());
item.setDfltParentId(def.parentId);
item.setDfltSortOrder(def.sortOrder);
item.setDfltVisibleYn("Y");
}
// ─────────────── 배치 ───────────────
private void seedPlacementsIfEmpty() {
if (menuPlacementRepository.count() > 0) {
log.info("메뉴 배치 존재 - 최초 시딩 건너뜀 (관리자 배치 보존)");
return;
}
List<PortalMenuItem> portalItems =
menuItemRepository.findAllBySourceType(PortalMenuItem.SOURCE_PORTAL);
List<PortalMenuPlacement> placements = menuDataService.buildDefaultPlacements(portalItems);
placements.forEach(placement -> placement.setCreatedBy(SYSTEM_AUDITOR));
menuPlacementRepository.saveAll(placements);
log.info("메뉴 배치 최초 시딩 완료 - {}건", placements.size());
}
// ─────────────── 평탄화 / 검증 ───────────────
private List<FlatMenuDef> flatten(List<PortalMenuYmlProperties.MenuItemDef> roots) {
List<FlatMenuDef> result = new ArrayList<>();
int sortOrder = 10;
for (PortalMenuYmlProperties.MenuItemDef root : roots) {
result.add(new FlatMenuDef(root, null, sortOrder, null));
int childOrder = 10;
for (PortalMenuYmlProperties.MenuItemDef child : root.getChildren()) {
result.add(new FlatMenuDef(child, root.getId(), childOrder, root));
childOrder += 10;
}
sortOrder += 10;
}
return result;
}
private void validate(List<FlatMenuDef> defs) {
Set<String> seen = new HashSet<>();
for (FlatMenuDef def : defs) {
if (def.id == null || !MENU_ID_PATTERN.matcher(def.id).matches()) {
throw new IllegalStateException("menu.yml 항목 id 형식 오류(kebab-case 필수): " + def.id);
}
if (!seen.add(def.id)) {
throw new IllegalStateException("menu.yml 항목 id 중복: " + def.id);
}
}
}
/** yml 트리 항목의 평탄화 뷰 (부모/기본 정렬 포함) */
private static class FlatMenuDef {
final PortalMenuYmlProperties.MenuItemDef source;
final String parentId;
final Integer sortOrder;
final PortalMenuYmlProperties.MenuItemDef parent;
final String id;
FlatMenuDef(PortalMenuYmlProperties.MenuItemDef source, String parentId, Integer sortOrder,
PortalMenuYmlProperties.MenuItemDef parent) {
this.source = source;
this.parentId = parentId;
this.sortOrder = sortOrder;
this.parent = parent;
this.id = source.getId();
}
String name() {
return source.getName();
}
String path() {
return trimToNull(source.getPath());
}
/** 자식은 섹션 미지정 시 부모 섹션 상속 */
String section() {
String own = trimToNull(source.getSection());
if (own != null) {
return own;
}
if (parent != null) {
String parentSection = trimToNull(parent.getSection());
if (parentSection != null) {
return parentSection;
}
}
return PortalMenuItem.SECTION_GNB;
}
String exposeCsv() {
return toCsv(source.getExposeRoles());
}
String accessCsv() {
return toCsv(source.getAccessRoles());
}
private static String toCsv(List<String> roles) {
if (roles == null || roles.isEmpty()) {
return null;
}
return String.join(",", roles);
}
private static String trimToNull(String value) {
if (value == null || value.trim().isEmpty()) {
return null;
}
return value.trim();
}
}
}
@@ -0,0 +1,179 @@
package com.eactive.apim.portal.djb.menu;
import com.eactive.apim.portal.menu.entity.PortalMenuItem;
import com.eactive.apim.portal.menu.entity.PortalMenuPlacement;
import com.eactive.apim.portal.menu.service.PortalMenuDataService;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 메뉴 트리 스냅샷 캐시.
*
* <p>매 요청 DB 조회를 피하기 위해 role 비의존 트리를 메모리에 유지한다
* (기존 수제 캐시 관례: PageService 의 volatile 필드). TTL 은
* PTL_PROPERTY {@code Portal / menu.cache.ttl-seconds} (기본 3600초)이며,
* eapim-admin 의 reload 명령(/internal/menu/reload)으로 즉시 갱신된다.
* 요청별 role 필터링은 {@link MenuModelAdvice} 가 수행한다.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class MenuService {
static final String PROP_GROUP = "Portal";
static final String PROP_CACHE_TTL = "menu.cache.ttl-seconds";
static final String DEFAULT_CACHE_TTL = "3600";
/** 스냅샷이 비어있을 때의 재시도 TTL(초) — 시딩 전 기동 직후 요청 대비 */
private static final long EMPTY_RETRY_TTL_SECONDS = 60;
private final PortalMenuDataService menuDataService;
private final PortalPropertyService portalPropertyService;
private volatile MenuSnapshot snapshot;
public MenuSnapshot getSnapshot() {
MenuSnapshot current = snapshot;
if (current != null && !current.isExpired()) {
return current;
}
synchronized (this) {
current = snapshot;
if (current != null && !current.isExpired()) {
return current;
}
return reload();
}
}
/**
* DB 에서 트리를 재구축한다. 시더 완료 시점과 admin reload 명령이 호출한다.
*/
public synchronized MenuSnapshot reload() {
long ttlSeconds = resolveTtlSeconds();
List<PortalMenuItem> items = menuDataService.loadAllItems();
List<PortalMenuPlacement> placements = menuDataService.loadAllPlacements();
Map<String, PortalMenuItem> itemsById = items.stream()
.collect(Collectors.toMap(PortalMenuItem::getMenuId, item -> item, (a, b) -> a, LinkedHashMap::new));
// 가시(visible=Y) 배치만 렌더 트리에 포함
Map<String, List<PortalMenuPlacement>> byParent = placements.stream()
.filter(PortalMenuPlacement::isVisible)
.filter(placement -> itemsById.containsKey(placement.getMenuId()))
.collect(Collectors.groupingBy(
placement -> placement.getParentId() == null ? "" : placement.getParentId(),
LinkedHashMap::new, Collectors.toList()));
List<MenuNode> roots = buildNodes(byParent.getOrDefault("", Collections.emptyList()), itemsById, byParent);
// 접근 권한 맵은 배치/노출과 무관하게 항목 기준으로 구성 (숨김 메뉴도 접근 통제 유지)
Map<String, List<String>> accessRolesByPath = new LinkedHashMap<>();
for (PortalMenuItem item : items) {
if (item.getMenuPath() != null && item.getAccessRoles() != null
&& !item.getAccessRoles().trim().isEmpty()) {
accessRolesByPath.put(normalizePath(item.getMenuPath()), splitCsv(item.getAccessRoles()));
}
}
if (roots.isEmpty()) {
ttlSeconds = Math.min(ttlSeconds, EMPTY_RETRY_TTL_SECONDS);
}
MenuSnapshot rebuilt = new MenuSnapshot(roots, accessRolesByPath, items.size(),
System.currentTimeMillis(), ttlSeconds);
snapshot = rebuilt;
log.info("메뉴 캐시 갱신 - 항목 {}건, 최상위 {}건, TTL {}초", items.size(), roots.size(), ttlSeconds);
return rebuilt;
}
private List<MenuNode> buildNodes(List<PortalMenuPlacement> placements,
Map<String, PortalMenuItem> itemsById,
Map<String, List<PortalMenuPlacement>> byParent) {
List<MenuNode> nodes = new ArrayList<>();
for (PortalMenuPlacement placement : placements) {
PortalMenuItem item = itemsById.get(placement.getMenuId());
MenuNode node = toNode(item);
node.setChildren(buildNodes(
byParent.getOrDefault(item.getMenuId(), Collections.emptyList()), itemsById, byParent));
nodes.add(node);
}
return nodes;
}
private MenuNode toNode(PortalMenuItem item) {
MenuNode node = new MenuNode();
node.setMenuId(item.getMenuId());
node.setName(item.getMenuName());
node.setPath(normalizePath(item.getMenuPath()));
node.setGroup(item.isGroup());
node.setSection(item.getMenuSection());
node.setIcon(item.getIconClass());
node.setNewWindow("Y".equals(item.getNewWindowYn()));
node.setExposeRoles(splitCsv(item.getExposeRoles()));
node.setAccessRoles(splitCsv(item.getAccessRoles()));
return node;
}
private long resolveTtlSeconds() {
try {
String value = portalPropertyService.getOrCreateProperty(PROP_GROUP, PROP_CACHE_TTL,
DEFAULT_CACHE_TTL, "포탈 메뉴 캐시 TTL(초)");
return Long.parseLong(value.trim());
} catch (Exception e) {
log.warn("메뉴 캐시 TTL 조회 실패 - 기본값 {}초 사용", DEFAULT_CACHE_TTL, e);
return Long.parseLong(DEFAULT_CACHE_TTL);
}
}
private static List<String> splitCsv(String csv) {
if (csv == null || csv.trim().isEmpty()) {
return Collections.emptyList();
}
return Arrays.stream(csv.split(","))
.map(String::trim)
.filter(token -> !token.isEmpty())
.collect(Collectors.toList());
}
private static String normalizePath(String path) {
if (path == null || path.trim().isEmpty()) {
return null;
}
String normalized = path.trim();
int queryIndex = normalized.indexOf('?');
return queryIndex >= 0 ? normalized.substring(0, queryIndex) : normalized;
}
/** role 비의존 메뉴 스냅샷 (불변 취급) */
@Getter
public static class MenuSnapshot {
private final List<MenuNode> roots;
private final Map<String, List<String>> accessRolesByPath;
private final int itemCount;
private final long loadedAt;
private final long ttlSeconds;
MenuSnapshot(List<MenuNode> roots, Map<String, List<String>> accessRolesByPath,
int itemCount, long loadedAt, long ttlSeconds) {
this.roots = roots;
this.accessRolesByPath = accessRolesByPath;
this.itemCount = itemCount;
this.loadedAt = loadedAt;
this.ttlSeconds = ttlSeconds;
}
boolean isExpired() {
return System.currentTimeMillis() - loadedAt > ttlSeconds * 1000L;
}
}
}
@@ -0,0 +1,108 @@
package com.eactive.apim.portal.djb.menu;
import lombok.Getter;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 요청별(role 필터 적용) 메뉴 뷰 — 템플릿 모델 {@code menuView}.
*
* <ul>
* <li>{@link #getGnb()} — 상단 글로벌 네비(GNB 섹션 최상위)</li>
* <li>{@link #getMypage()} — 마이페이지 드롭다운 루트 (미노출 시 null)</li>
* <li>{@link #activeGroup(String)} — service_sidebar 용 활성 그룹 탐색</li>
* </ul>
*/
@Getter
public class MenuView {
/**
* service_sidebar 기존 호출부의 activeMenu 키 → 메뉴 ID 브리지.
* 호출 페이지 수정 없이 DB 메뉴 기반 사이드바로 전환하기 위한 레거시 맵.
*/
private static final Map<String, String> LEGACY_ACTIVE_KEYS = new HashMap<>();
static {
LEGACY_ACTIVE_KEYS.put("intro", "service-intro");
LEGACY_ACTIVE_KEYS.put("guide", "service-guide");
LEGACY_ACTIVE_KEYS.put("oauth2", "service-oauth2-guide");
LEGACY_ACTIVE_KEYS.put("webhookGuide", "service-webhook-dev-guide");
LEGACY_ACTIVE_KEYS.put("notice", "support-notice");
LEGACY_ACTIVE_KEYS.put("faq", "support-faq");
LEGACY_ACTIVE_KEYS.put("qna", "support-qna");
LEGACY_ACTIVE_KEYS.put("feedback", "support-partnership");
LEGACY_ACTIVE_KEYS.put("users", "my-page-users");
LEGACY_ACTIVE_KEYS.put("apiKey", "my-page-clients");
LEGACY_ACTIVE_KEYS.put("webhook", "my-page-webhook");
LEGACY_ACTIVE_KEYS.put("statistics", "my-page-statistics");
LEGACY_ACTIVE_KEYS.put("profile", "my-page-profile");
LEGACY_ACTIVE_KEYS.put("password", "my-page-password");
}
private final List<MenuNode> gnb;
private final MenuNode mypage;
private final String currentPath;
public MenuView(List<MenuNode> gnb, MenuNode mypage, String currentPath) {
this.gnb = gnb;
this.mypage = mypage;
this.currentPath = currentPath;
}
/**
* 사이드바용 활성 그룹. legacy activeMenu 키 → 메뉴 ID 우선,
* 실패 시 현재 경로(정확/prefix) 매칭으로 폴백.
*/
public MenuNode activeGroup(String activeMenu) {
String targetId = activeMenu == null ? null : LEGACY_ACTIVE_KEYS.get(activeMenu);
MenuNode byId = targetId == null ? null : findRootContaining(node -> targetId.equals(node.getMenuId()));
if (byId != null) {
return byId;
}
return findRootContaining(this::matchesCurrentPath);
}
/**
* 사이드바 항목 활성 여부: legacy 키 매칭 우선, 경로 매칭 폴백.
*/
public boolean isActive(MenuNode node, String activeMenu) {
String targetId = activeMenu == null ? null : LEGACY_ACTIVE_KEYS.get(activeMenu);
if (targetId != null) {
return targetId.equals(node.getMenuId());
}
return matchesCurrentPath(node);
}
private MenuNode findRootContaining(java.util.function.Predicate<MenuNode> predicate) {
for (MenuNode root : allRoots()) {
if (predicate.test(root) || root.getChildren().stream().anyMatch(predicate)) {
return root;
}
}
return null;
}
private List<MenuNode> allRoots() {
if (mypage == null) {
return gnb;
}
java.util.ArrayList<MenuNode> roots = new java.util.ArrayList<>(gnb);
roots.add(mypage);
return roots;
}
private boolean matchesCurrentPath(MenuNode node) {
if (!node.hasPath() || currentPath == null) {
return false;
}
return currentPath.equals(node.getPath())
|| currentPath.startsWith(node.getPath() + "/");
}
public static MenuView empty() {
return new MenuView(Collections.emptyList(), null, null);
}
}
@@ -0,0 +1,38 @@
package com.eactive.apim.portal.djb.menu;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
/**
* menu.yml 바인딩 (spring.config.import 로 로드).
* 트리(children 중첩) 형태 그대로 바인딩하며, 평탄화는 {@link MenuSeeder} 가 수행한다.
*/
@Data
@Component
@ConfigurationProperties(prefix = "portal-menu")
public class PortalMenuYmlProperties {
private List<MenuItemDef> items = new ArrayList<>();
@Data
public static class MenuItemDef {
/** kebab-case 자연키 (^[a-z0-9-]+$) */
private String id;
private String name;
private String path;
/** true = 클릭 없는 상위 그룹 */
private boolean group;
/** GNB(기본) | MYPAGE */
private String section;
/** FontAwesome 아이콘 클래스 (마이페이지 드롭다운) */
private String icon;
private boolean newWindow;
private List<String> exposeRoles = new ArrayList<>();
private List<String> accessRoles = new ArrayList<>();
private List<MenuItemDef> children = new ArrayList<>();
}
}
@@ -0,0 +1,47 @@
package com.eactive.apim.portal.djb.menu;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* roles.yml 바인딩 (spring.config.import 로 로드).
* 기존 application.yml 의 {@code portal.portal_security} 를 대체한다.
* 로그인 시 권한 확장(PortalUserAuthService)이 직접 사용하며,
* DB 미러(PTL_ROLE / PTL_ROLE_AUTHORITY)는 {@link MenuSeeder} 가 적재한다.
*/
@Data
@Component
@ConfigurationProperties(prefix = "portal-roles")
public class PortalRolesProperties {
private List<RoleDef> roles = new ArrayList<>();
/** AUTHORITY 유형 역할 코드 → 한글 라벨 (admin UI 표기용) */
private Map<String, String> authorityNames = new LinkedHashMap<>();
@Data
public static class RoleDef {
private String code;
private String name;
private List<String> authorities = new ArrayList<>();
}
/**
* 기본 역할의 파생 권한 목록. 미정의 역할이면 빈 목록.
*/
public List<String> getAuthorities(RoleCode roleCode) {
return roles.stream()
.filter(role -> role.getCode().equals(roleCode.name()))
.findFirst()
.map(RoleDef::getAuthorities)
.orElse(Collections.emptyList());
}
}
+5 -20
View File
@@ -23,6 +23,10 @@ server:
spring:
config:
import:
- classpath:menu.yml
- classpath:roles.yml
data:
web:
pageable:
@@ -218,26 +222,7 @@ portal:
view-name: apps/apis/static/tokenApiSpec
bean: apiHandler
portal_security:
ROLE_USER:
- ROLE_INQUIRY
- ROLE_ACCOUNT
ROLE_CORP_USER:
- ROLE_API_KEY_REQUEST
- ROLE_API_KEY_REQUEST_VIEW
- ROLE_INQUIRY
- ROLE_APP
- ROLE_ACCOUNT
ROLE_CORP_MANAGER:
- ROLE_API_KEY_REQUEST
- ROLE_API_KEY_REQUEST_VIEW
- ROLE_WEBHOOK
- ROLE_INQUIRY
- ROLE_APP
- ROLE_ACCOUNT
- ROLE_CORP_API
- ROLE_DASHBOARD
- ROLE_USER_MANAGER
# portal_security 는 roles.yml (portal-roles) 로 이동
page:
home:
name: "Home"
+55
View File
@@ -0,0 +1,55 @@
# ------------------------------------------------------------------------------
# 포탈 GNB 메뉴 정의 (menu.yml)
#
# - 부팅 시 PTL_MENU_ITEM 으로 자동 적재(upsert by id). 동일 id 의 내용이 바뀌면
# 기본값(DFLT_*)이 갱신되고, 관리자가 수정하지 않은 필드만 새 기본값을 따라간다.
# - 배치(위치) 정보는 PTL_MENU_PLACEMENT 가 비어있을 때만 최초 적재된다.
# 이후 배치 관리는 eapim-admin "포탈메뉴관리" 화면에서 수행.
# - id: kebab-case (^[a-z0-9-]+$). 변경 시 새 항목으로 인식되므로 변경 금지.
# - group: true → 상위 그룹(클릭 없음). path 를 주면 그룹도 링크 동작.
# - section: GNB(기본) | MYPAGE(마이페이지 드롭다운)
# - expose-roles / access-roles: 생략=전체 허용, AUTHENTICATED=로그인 사용자,
# 그 외 역할 코드 나열 시 하나라도 보유하면 허용(any-of)
# ------------------------------------------------------------------------------
portal-menu:
items:
- id: service
name: "서비스 소개"
group: true
children:
- { id: service-intro, name: "API 포탈 소개", path: /service/intro }
- { id: service-guide, name: "회원가입 안내", path: /service/guide }
- { id: service-oauth2-guide, name: "OAuth2 개발가이드", path: /service/oauth2-guide }
- { id: service-webhook-dev-guide, name: "웹훅 개발가이드", path: /service/webhook-dev-guide }
- { id: open-api, name: "오픈 API", path: /apis }
- id: support
name: "고객지원"
group: true
children:
- { id: support-notice, name: "공지사항", path: /portalnotice }
- { id: support-faq, name: "FAQ", path: /faq_list }
- { id: support-qna, name: "Q&A", path: /inquiry }
- { id: support-partnership, name: "피드백/개선요청", path: /partnership }
- { id: api-status, name: "API Status", path: /apistatus }
- id: my-page
name: "마이페이지"
group: true
section: MYPAGE
expose-roles: [AUTHENTICATED]
children:
- { id: my-page-users, name: "개발자 관리", path: /users, icon: fa-users,
expose-roles: [ROLE_CORP_MANAGER], access-roles: [ROLE_CORP_MANAGER] }
- { id: my-page-clients, name: "API 신청 관리", path: /clients, icon: fa-key,
expose-roles: [ROLE_APP], access-roles: [ROLE_APP] }
- { id: my-page-webhook, name: "Webhook 관리", path: /webhook, icon: fa-bell,
expose-roles: [ROLE_WEBHOOK], access-roles: [ROLE_WEBHOOK] }
- { id: my-page-statistics, name: "이용 통계", path: /statistics/api, icon: fa-chart-bar,
expose-roles: [ROLE_APP], access-roles: [ROLE_APP] }
- { id: my-page-profile, name: "내 정보 관리", path: /mypage, icon: fa-user-circle,
expose-roles: [AUTHENTICATED], access-roles: [AUTHENTICATED] }
- { id: my-page-password, name: "비밀번호 변경", path: /password/change, icon: fa-lock,
expose-roles: [AUTHENTICATED], access-roles: [AUTHENTICATED] }
+47
View File
@@ -0,0 +1,47 @@
# ------------------------------------------------------------------------------
# 포탈 역할 정의 (roles.yml)
#
# - 기존 application.yml 의 portal.portal_security 를 이동한 파일.
# - 로그인 시 권한 확장(PortalUserAuthService)은 이 파일 바인딩을 직접 사용한다.
# - 부팅 시 PTL_ROLE / PTL_ROLE_AUTHORITY 로 미러 적재되며,
# 해당 테이블은 eapim-admin 메뉴 권한 선택 UI 소스로만 소비된다.
# ------------------------------------------------------------------------------
portal-roles:
roles:
- code: ROLE_USER
name: "개인사용자"
authorities:
- ROLE_INQUIRY
- ROLE_ACCOUNT
- code: ROLE_CORP_USER
name: "법인사용자"
authorities:
- ROLE_API_KEY_REQUEST
- ROLE_API_KEY_REQUEST_VIEW
- ROLE_INQUIRY
- ROLE_APP
- ROLE_ACCOUNT
- code: ROLE_CORP_MANAGER
name: "법인관리자"
authorities:
- ROLE_API_KEY_REQUEST
- ROLE_API_KEY_REQUEST_VIEW
- ROLE_WEBHOOK
- ROLE_INQUIRY
- ROLE_APP
- ROLE_ACCOUNT
- ROLE_CORP_API
- ROLE_DASHBOARD
- ROLE_USER_MANAGER
# PTL_ROLE(AUTHORITY 유형) 한글 라벨 — admin 권한 선택 UI 표기용
authority-names:
ROLE_INQUIRY: "문의 작성"
ROLE_ACCOUNT: "계정 관리"
ROLE_API_KEY_REQUEST: "인증키 신청"
ROLE_API_KEY_REQUEST_VIEW: "인증키 신청 조회"
ROLE_APP: "앱/API 신청 관리"
ROLE_WEBHOOK: "웹훅 관리"
ROLE_CORP_API: "법인 API 조회"
ROLE_DASHBOARD: "대시보드"
ROLE_USER_MANAGER: "개발자 관리"
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

@@ -7,6 +7,7 @@
const windowDays = parseInt(page.getAttribute('data-window-days'), 10) || 90;
const base = page.getAttribute('data-base') || '/apistatus';
const apiDetailBase = page.getAttribute('data-api-detail-base') || '/apis/detail';
const noticeDetailBase = page.getAttribute('data-notice-detail-base') || '/portalnotice/detail';
/** 오픈 API 목록에 노출되는 API (링크 가능 대상) */
const linkableApiIds = new Set();
@@ -174,21 +175,61 @@
// ---------------- 이슈 카드 ----------------
/**
* 영향 API 태그. 오픈 API 목록에 있는 API 만 상세 화면으로 링크하고,
* 목록에 없는 API(비공개·미편성)는 링크 없이 흐리게 표시한다.
* 영향 API 태그.
*
* 서버가 이미 현재 사용자에게 게시된 API 만 내려주므로 여기 오는 항목은 전부 링크 대상이다.
* 게시되지 않은 GW 인터페이스는 개별 노출하지 않고 hiddenApiCount 로만 받아 건수로 묶는다.
*/
function apiPillsHtml(apis) {
if (!apis || !apis.length) return '';
const pills = apis.map(function (api) {
function apiPillsHtml(apis, hiddenCount) {
const hidden = hiddenCount || 0;
if ((!apis || !apis.length) && hidden === 0) return '';
const pills = (apis || []).map(function (api) {
const text = escapeHtml(api.apiName || api.apiId);
if (!linkableApiIds.has(api.apiId)) {
return '<span class="as-api-pill is-unlinked" title="공개된 API 목록에 없는 API 입니다">'
+ text + '</span>';
return '<span class="as-api-pill is-unlinked">' + text + '</span>';
}
return '<a class="as-api-pill" href="' + apiDetailBase + '?id=' + encodeURIComponent(api.apiId) + '">'
+ text + '</a>';
}).join('');
return '<div class="as-api-pills"><span>영향 API:</span>' + pills + '</div>';
// 개발자포탈에 게시되지 않은 GW 인터페이스는 개별 노출 없이 건수로만 알린다
const hiddenPill = hidden > 0
? '<span class="as-api-pill is-gw" title="개발자포탈에 게시되지 않은 게이트웨이 인터페이스입니다">'
+ 'GW 인터페이스 ' + hidden + '건</span>'
: '';
return '<div class="as-api-pills"><span>영향 API:</span>' + pills + hiddenPill + '</div>';
}
/**
* 이슈에 연결된 공지 본문. 서버가 게시 중(USE_YN='Y')인 공지만 내려주므로 여기서는 존재 여부만 본다.
* 본문은 관리자가 에디터로 작성한 HTML 이라 공지 상세(th:utext)와 같이 그대로 렌더한다.
*/
function noticeHtml(issue) {
if (!issue.noticeDetail) return '';
const link = issue.noticeId
? '<a class="as-alert-notice-link" href="' + noticeDetailBase + '?id='
+ encodeURIComponent(issue.noticeId) + '">공지 전체 보기 →</a>'
: '';
return '<div class="as-alert-notice">'
+ '<div class="as-alert-notice-head">'
+ '<span class="as-alert-notice-label">공지사항</span>'
+ (issue.noticeSubject ? '<strong>' + escapeHtml(issue.noticeSubject) + '</strong>' : '')
+ '</div>'
+ '<div class="as-alert-notice-body editor-content">' + issue.noticeDetail + '</div>'
+ link
+ '</div>';
}
/**
* 타임라인 | 공지 2열 배치. 데스크탑에서만 두 칸으로 갈라지고 좁은 화면에서는 세로로 쌓인다.
* 한쪽만 있으면 감싸지 않는다 (혼자 반쪽 폭을 차지하지 않게).
*/
function splitHtml(left, right) {
if (!left) return right;
if (!right) return left;
return '<div class="as-card-split">' + left + right + '</div>';
}
function issueCardHtml(issue) {
@@ -222,8 +263,8 @@
+ (issue.stateLabel ? '<span>' + escapeHtml(issue.stateLabel) + '</span>' : '')
+ '</div>'
+ '<h3 class="as-issue-title">' + escapeHtml(issue.title) + '</h3>'
+ inner
+ apiPillsHtml(issue.impactedApis)
+ splitHtml(inner, noticeHtml(issue))
+ apiPillsHtml(issue.impactedApis, issue.hiddenApiCount)
+ '</article>';
}
+56 -14
View File
@@ -8,6 +8,7 @@
const authenticated = page.classList.contains('is-authenticated');
const base = page.getAttribute('data-base') || '/apistatus';
const apiDetailBase = page.getAttribute('data-api-detail-base') || '/apis/detail';
const noticeDetailBase = page.getAttribute('data-notice-detail-base') || '/portalnotice/detail';
/** 오픈 API 목록에 노출되는 API (링크 가능 대상) */
const linkableApiIds = new Set();
@@ -87,21 +88,60 @@
}
/**
* 영향 API 태그. 오픈 API 목록에 있는 API 만 상세 화면으로 링크하고,
* 목록에 없는 API(비공개·미편성)는 링크 없이 흐리게 표시한다.
* 영향 API 태그.
*
* 서버가 이미 현재 사용자에게 게시된 API 만 내려주므로 여기 오는 항목은 전부 링크 대상이다.
* 게시되지 않은 GW 인터페이스는 개별 노출하지 않고 hiddenApiCount 로만 받아 건수로 묶는다.
*/
function apiPillsHtml(apis, label) {
if (!apis || !apis.length) return '';
const pills = apis.map(function (api) {
function apiPillsHtml(apis, label, hiddenCount) {
const hidden = hiddenCount || 0;
if ((!apis || !apis.length) && hidden === 0) return '';
const pills = (apis || []).map(function (api) {
const text = escapeHtml(api.apiName || api.apiId);
if (!linkableApiIds.has(api.apiId)) {
return '<span class="as-api-pill is-unlinked" title="공개된 API 목록에 없는 API 입니다">'
+ text + '</span>';
return '<span class="as-api-pill is-unlinked">' + text + '</span>';
}
return '<a class="as-api-pill" href="' + apiDetailBase + '?id=' + encodeURIComponent(api.apiId) + '">'
+ text + '</a>';
}).join('');
return '<div class="as-api-pills"><span>' + escapeHtml(label) + '</span>' + pills + '</div>';
const hiddenPill = hidden > 0
? '<span class="as-api-pill is-gw" title="개발자포탈에 게시되지 않은 게이트웨이 인터페이스입니다">'
+ 'GW 인터페이스 ' + hidden + '건</span>'
: '';
return '<div class="as-api-pills"><span>' + escapeHtml(label) + '</span>' + pills + hiddenPill + '</div>';
}
/**
* 이슈에 연결된 공지 본문. 서버가 게시 중(USE_YN='Y')인 공지만 내려주므로 여기서는 존재 여부만 본다.
* 본문은 관리자가 에디터로 작성한 HTML 이라 공지 상세(th:utext)와 같이 그대로 렌더한다.
*/
function noticeHtml(incident) {
if (!incident.noticeDetail) return '';
const link = incident.noticeId
? '<a class="as-alert-notice-link" href="' + noticeDetailBase + '?id='
+ encodeURIComponent(incident.noticeId) + '">공지 전체 보기 →</a>'
: '';
return '<div class="as-alert-notice">'
+ '<div class="as-alert-notice-head">'
+ '<span class="as-alert-notice-label">공지사항</span>'
+ (incident.noticeSubject ? '<strong>' + escapeHtml(incident.noticeSubject) + '</strong>' : '')
+ '</div>'
+ '<div class="as-alert-notice-body editor-content">' + incident.noticeDetail + '</div>'
+ link
+ '</div>';
}
/**
* 타임라인 | 공지 2열 배치. 데스크탑에서만 두 칸으로 갈라지고 좁은 화면에서는 세로로 쌓인다.
* 한쪽만 있으면 감싸지 않는다 (혼자 반쪽 폭을 차지하지 않게).
*/
function splitHtml(left, right) {
if (!left) return right;
if (!right) return left;
return '<div class="as-card-split">' + left + right + '</div>';
}
// ---------------- ❶ 진행 중 장애 ----------------
@@ -139,8 +179,9 @@
+ '</div>'
+ '<h2 class="as-alert-title">' + escapeHtml(incident.title) + '</h2>'
+ (incident.summary ? '<p class="as-meta">' + escapeHtml(incident.summary) + '</p>' : '')
+ apiPillsHtml(incident.apis, '영향 API:')
+ (timeline ? '<div class="as-alert-timeline">' + timeline + '</div>' : '')
+ apiPillsHtml(incident.apis, '영향 API:', incident.hiddenApiCount)
+ splitHtml(timeline ? '<div class="as-alert-timeline">' + timeline + '</div>' : '',
noticeHtml(incident))
+ '</section>';
}).join('');
@@ -252,8 +293,9 @@
+ ' <h3 class="as-maint-title">' + escapeHtml(card.title) + '</h3>'
+ ' <span class="as-schedule">' + escapeHtml(phase + ' · ' + range) + '</span>'
+ '</div>'
+ (card.summary ? '<div class="as-maint-body">' + escapeHtml(card.summary) + '</div>' : '')
+ apiPillsHtml(card.impactedApis, '영향 API:')
+ splitHtml(card.summary ? '<div class="as-maint-body">' + escapeHtml(card.summary) + '</div>' : '',
noticeHtml(card))
+ apiPillsHtml(card.impactedApis, '영향 API:', card.hiddenApiCount)
+ '<div class="as-maint-meta">등록 ' + escapeHtml(formatDateTime(card.registeredAt))
+ (card.lastModifiedAt ? ' (최종 수정 ' + escapeHtml(formatDateTime(card.lastModifiedAt)) + ')' : '')
+ '</div>'
@@ -300,8 +342,8 @@
+ (issue.stateLabel ? '<span>' + escapeHtml(issue.stateLabel) + '</span>' : '')
+ ' </div>'
+ ' <h3 class="as-issue-title">' + escapeHtml(issue.title) + '</h3>'
+ inner
+ apiPillsHtml(issue.impactedApis, '영향 API:')
+ splitHtml(inner, noticeHtml(issue))
+ apiPillsHtml(issue.impactedApis, '영향 API:', issue.hiddenApiCount)
+ '</article>'
+ '</div>';
}
@@ -285,20 +285,40 @@
}
.pulsing {
animation: pulse 2s ease-in-out infinite;
transform-origin: 400px 110px;
transform-box: fill-box;
animation: pulseLine 2s ease-in-out infinite;
}
@keyframes pulseLine {
0%,
100% {
transform: translate(0, 0) scale(1);
opacity: 0.9;
}
50% {
transform: translateX(75px) scale(1.1);
/* 하나의 transform에 한 줄로 작성 */
opacity: 0.7;
}
}
@keyframes pulse {
0%,
100% {
transform: scale(1);
transform: translate(0, 0) scale(1);
opacity: 0.9;
}
50% {
transform: scale(1.1);
opacity: 0.5;
transform: translateX(75px) scale(1.1);
/* 하나의 transform에 한 줄로 작성 */
opacity: 0.7;
}
}
@@ -183,6 +183,7 @@ textarea.djb-comment-input {
font-family: inherit;
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
background-color: #fcfcfc;
&::placeholder {
color: #7f8a95;
@@ -1010,6 +1010,7 @@ select.form-control {
resize: none;
transition: border-color 0.2s;
min-height: 250px;
background-color: #fcfcfc;
&::placeholder {
color: #94A3B8;
@@ -49,6 +49,7 @@
padding: 10px 20px;
border-radius: 8px;
transition: var(--transition-smooth);
white-space: nowrap;
&:hover {
background-color: var(--accent-light);
@@ -63,6 +64,7 @@
background-color: var(--primary-color);
padding: 10px 24px;
border-radius: 8px;
white-space: nowrap;
box-shadow: 0 4px 12px rgba(0, 73, 180, 0.15);
transition: var(--transition-smooth);
@@ -199,7 +199,7 @@
p {
margin: 0;
& + p {
&+p {
margin-top: $spacing-md;
}
}
@@ -240,7 +240,6 @@
.btn,
.btn-modal-cancel,
.btn-modal-confirm {
flex: 1;
min-width: 0;
height: 46px;
font-size: 15px;
@@ -330,4 +329,4 @@
font-size: $font-size-base;
}
}
}
}
@@ -89,7 +89,7 @@
}
}
@media (max-width: 768px) {
@media (max-width: 1024px) {
height: clamp(44px, 11.73vw, 60px);
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(10px);
@@ -128,9 +128,31 @@
.header-left {
display: flex;
align-items: center;
// 헤더가 좁아져도 로고 영역은 줄이지 않는다 (줄임은 가운데 메뉴가 감당)
flex-shrink: 0;
.logo {
height: 32px;
// 로고 워드마크(DJ Bank) 밑선에 "API Portal" 글자 baseline 맞춤
align-items: flex-end;
flex-wrap: nowrap;
// width 가 고정(114px)이라 flex 축소가 걸리면 가로만 눌려 비율이 깨진다
img { flex-shrink: 0; }
.mobile-logo-link {
align-items: flex-end;
flex-shrink: 0;
}
.mobile-logo-text {
align-items: flex-end;
line-height: 1;
padding-bottom: 2px;
// "API / Portal" 로 접히지 않게
white-space: nowrap;
flex-shrink: 0;
}
}
}
@@ -158,7 +180,7 @@
// Logo with modern styling
.logo {
display: block;
display: flex;
height: 32px;
vertical-align: middle;
z-index: 10;
@@ -202,7 +224,6 @@
// Logo link styles
.logo-link,
.mobile-logo-link,
.drawer-logo-link {
display: inline-block;
text-decoration: none;
@@ -252,7 +273,7 @@
justify-content: space-between;
width: 100%;
@media (max-width: 768px) {
@media (max-width: 1024px) {
display: none;
}
}
@@ -263,7 +284,7 @@
justify-content: space-between;
width: 100%;
@media (max-width: 768px) {
@media (max-width: 1024px) {
display: flex;
}
}
@@ -937,7 +958,7 @@
.sub-menu {
position: absolute;
top: calc(100% + 8px);
top: calc(100% + 17px);
left: 0;
min-width: 200px;
background: var(--white);
@@ -982,6 +1003,8 @@
padding: 12px;
position: relative;
transition: var(--transition-smooth);
// 폭이 좁아져도 "서비스 소 / 개" 처럼 메뉴명이 잘리지 않게 한다
white-space: nowrap;
&:hover {
color: var(--primary-blue);
@@ -1096,7 +1119,7 @@
// Mobile Responsive Design
// 375px 기준 반응형 - clamp() 사용하여 화면 크기에 비례
// ===========================
@media (max-width: 768px) {
@media (max-width: 1024px) {
.global-header {
// Figma: 44px 높이, 반투명 배경
// 375px 기준 44px → 44/375*100 ≈ 11.73vw, 범위: 44px ~ 60px
@@ -1247,7 +1270,7 @@
}
// Mobile responsive
@media (max-width: 768px) {
@media (max-width: 1024px) {
.header-user-info {
display: none; // Hide on mobile, use drawer instead
}
@@ -571,7 +571,7 @@
// Result Page Styles (아이디 찾기 결과 페이지)
.account-recovery-result {
width: 100%;
padding: 60px 40px;
padding: 35px 0px;
border-radius: 0 0 12px 12px;
text-align: center;
@@ -690,6 +690,44 @@
border-radius: 12px;
padding: 16px 24px;
&.info-box-highlight {
background: linear-gradient(135deg, rgba(0, 73, 180, 0.06) 0%, rgba(0, 73, 180, 0.02) 100%);
border: 1.5px solid rgba(0, 73, 180, 0.25);
box-shadow: 0 8px 24px rgba(0, 73, 180, 0.08);
display: flex;
align-items: center;
gap: 12px;
animation: fadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) both;
transform: translateY(15px);
opacity: 0;
.highlight-icon {
display: flex;
justify-content: center;
align-items: center;
animation: pulseIcon 2s infinite ease-in-out;
}
.info-text {
font-size: 16px;
line-height: 1.6;
color: #1e293b;
text-align: center;
strong {
color: #0049B4;
font-weight: 700;
font-size: 19px;
}
p {
color: #4685ef;
}
}
}
.info-text {
font-family: $font-family-primary;
font-size: 15px;
@@ -725,6 +763,27 @@
}
}
@keyframes fadeInUp {
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes pulseIcon {
0%,
100% {
transform: scale(1);
filter: drop-shadow(0 0 0px rgba(0, 73, 180, 0));
}
50% {
transform: scale(1.06);
filter: drop-shadow(0 0 6px rgba(0, 73, 180, 0.2));
}
}
// -----------------------------------------------------------------------------
// Mobile Design (sm: 768px breakpoint)
// -----------------------------------------------------------------------------
@@ -785,7 +844,7 @@
.auth-input-group {
.auth-timer {
font-size: 12px;
right: 100px;
right: 18px;
}
}
@@ -253,6 +253,83 @@
color: var(--as-muted);
cursor: default;
}
// 개발자포탈에 게시되지 않은 GW 인터페이스 묶음 ("GW 인터페이스 3건")
.as-api-pill.is-gw {
background: #fff;
border: 1px dashed var(--as-border-strong);
color: var(--as-muted);
cursor: default;
}
}
// 타임라인 | 공지 2열. 데스크탑(961px~)에서만 갈라지고 그 아래는 세로로 쌓인다.
// minmax(0, 1fr) 은 공지 본문의 긴 URL·표가 칸을 밀어내지 못하게 하는 grid 관용구.
.as-card-split {
margin-top: 16px;
display: grid;
gap: 16px;
align-items: start;
@media (min-width: 961px) {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
}
// 감싼 두 블록의 자체 세로 margin 은 grid gap 으로 대체한다.
// 카드 컨텍스트를 함께 써서 원 규칙(.as-alert-card .as-alert-timeline 등)보다 특이도를 높인다.
.as-card-split > .as-alert-notice { margin-top: 0; }
.as-alert-card .as-card-split > .as-alert-timeline { margin-top: 0; }
.as-maint-card .as-card-split > .as-maint-body { margin-top: 0; }
// 이슈에 연결된 공지 본문 (진행 중 장애 카드 · 점검 카드 공용).
// 본문 길이가 제각각이라 높이를 제한하고 안쪽만 스크롤시킨다.
.as-alert-notice {
margin-top: 16px;
border: 1px solid var(--as-border);
border-radius: var(--as-radius);
background: var(--as-gray-bg);
padding: 14px 16px;
.as-alert-notice-head {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 10px;
strong { font-size: 14px; color: var(--as-text); }
}
.as-alert-notice-label {
font-size: 11px;
font-weight: 500;
padding: 2px 8px;
border-radius: var(--as-pill);
background: var(--as-info-bg);
color: var(--as-info);
}
.as-alert-notice-body {
max-height: 320px;
overflow-y: auto;
font-size: 14px;
line-height: 1.7;
color: var(--as-text-2);
word-break: break-word;
img, table { max-width: 100%; }
p:last-child { margin-bottom: 0; }
}
.as-alert-notice-link {
display: inline-block;
margin-top: 12px;
font-size: 13px;
font-weight: 500;
color: var(--as-info);
text-decoration: none;
}
}
// ---------------- 진행 중 장애 카드 ----------------
@@ -168,16 +168,16 @@
.form-input {
width: 100%;
padding: 8px 12px;
border: 1px solid #dadada;
border: 1px solid #DFDFDF;
border-radius: $border-radius-md;
background-color: $white;
background-color: #fcfcfc;
font-size: 14px;
color: $text-dark;
outline: none;
transition: border-color 0.2s ease;
&::placeholder {
color: #dadada;
color: #94A3B8;
}
&:focus {
@@ -1130,7 +1130,7 @@ input[type="checkbox"]:checked+.custom-checkbox {
}
&:disabled {
background-color: #E2E8F0 !important;
background-color: #ededed !important;
}
}
@@ -2929,7 +2929,7 @@ input[type="checkbox"]:checked+.custom-checkbox {
.s1-title,
.s2-title,
.s3-title {
font-size: 30px;
font-size: 24px;
font-weight: 700;
color: #0d0e11;
line-height: 1;
@@ -3261,7 +3261,7 @@ input[type="checkbox"]:checked+.custom-checkbox {
padding: 12px 16px;
}
.s1-upload-inner > img {
.s1-upload-inner>img {
width: 28px;
height: 28px;
}
+14 -760
View File
@@ -423,342 +423,6 @@
// API Search section styles - 검색 섹션
// -----------------------------------------------------------------------------
/* 원래 있던 검색 영역 스타일 (주석 처리)
.api-search-section {
position: relative;
padding: 0;
//margin-top: -72px;
overflow: hidden;
background: #e9f9ff;
height: 283px;
.search-background {
position: absolute;
inset: 0;
background-image: url('/img/bg_main_intersect.svg');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
pointer-events: none;
}
.search-content-wrapper {
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: 36px;
z-index: 1;
@include respond-to('md') {
flex-direction: column;
gap: 22px;
padding-top: 36px;
}
@include respond-to('sm') {
// Figma 모바일: 캐릭터와 텍스트 가로 배치
flex-direction: row;
flex-wrap: wrap;
gap: 7px;
padding-top: 18px;
justify-content: center;
}
}
.search-character {
width: 139px;
height: 133px;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: contain;
}
@include respond-to('md') {
width: 108px;
height: 104px;
}
@include respond-to('sm') {
// Figma 모바일: 68x65px
width: 61px;
height: 59px;
}
}
.search-text-content {
text-align: left;
@include respond-to('md') {
text-align: center;
}
@include respond-to('sm') {
// Figma 모바일: 텍스트 왼쪽 정렬
text-align: left;
}
}
.search-title {
font-size: 32px;
line-height: 1.3;
color: #000000;
margin: 0;
@include respond-to('md') {
font-size: 25px;
}
@include respond-to('sm') {
// Figma 모바일: 14px, Bold
font-size: 14px;
line-height: 1.4;
color: #212529;
}
}
.search-input-wrapper {
position: relative;
display: flex;
justify-content: center;
padding: 0px 0 27px;
z-index: 1;
@include respond-to('md') {
padding: 27px 18px 18px;
}
@include respond-to('sm') {
// Figma 모바일: 좌측 24px, 우측 25px 패딩으로 좌측 정렬
justify-content: flex-start;
padding: 0px 25px 11px 24px;
}
}
.search-form {
width: 100%;
max-width: 816px;
@include respond-to('sm') {
// Figma 모바일: 288px 고정 너비
//width: 288px;
//max-width: 320px;
}
}
.search-box {
position: relative;
width: 100%;
height: 72px;
background: #FFFFFF;
border: 5px solid #0049B4;
border-radius: 45px;
display: flex;
align-items: center;
padding: 0 22px;
@include respond-to('md') {
height: 54px;
border: 4px solid #0049B4;
padding: 0 14px;
}
@include respond-to('sm') {
// Figma 모바일: 320x36px, 1px 파란색 테두리
height: 32px;
border: 3px solid #0049B4;
border-radius: 16px;
padding: 0 11px;
}
}
.search-input {
flex: 1;
width: 100%;
height: 100%;
border: none;
outline: none;
background: transparent;
font-family: $font-family-primary;
font-size: 15px;
font-weight: 500;
color: #000000;
padding: 0 18px;
&::placeholder {
color: #B3B3B3;
font-weight: 400;
}
@include respond-to('md') {
font-size: 14px;
padding: 0 11px;
}
@include respond-to('sm') {
// Figma 모바일: 10px, Medium, placeholder 색상 #8C959F
font-size: 10px;
font-weight: 500;
padding: 0 7px;
&::placeholder {
color: #8C959F;
font-weight: 500;
}
}
}
.search-icon-button {
background: none;
border: none;
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
flex-shrink: 0;
transition: transform 0.3s ease;
&:hover {
transform: scale(1.1);
}
&:active {
transform: scale(0.95);
}
svg {
width: 30px;
height: 31px;
@include respond-to('md') {
width: 25px;
height: 26px;
}
@include respond-to('sm') {
// Figma 모바일: 16x16px 아이콘
width: 14px;
height: 14px;
}
}
@include respond-to('sm') {
width: 18px;
height: 18px;
}
}
.hashtag-section {
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: 11px;
padding: 0 18px 0px;
z-index: 1;
@include respond-to('md') {
flex-direction: column;
align-items: center;
justify-content: center;
gap: 7px;
padding: 0 18px 27px;
}
@include respond-to('sm') {
// Figma 모바일: 가로 배치, 작은 간격
flex-direction: row;
flex-wrap: wrap;
gap: 11px;
padding: 0 18px 18px;
justify-content: center;
}
}
.hashtag-label {
font-family: $font-family-primary;
font-size: 14px;
font-weight: 700;
color: #000000;
white-space: nowrap;
@include respond-to('sm') {
// Figma 모바일: 11px, Bold
font-size: 11px;
color: #212529;
}
}
.hashtag-list {
display: flex;
align-items: center;
gap: 13px;
flex-wrap: wrap;
@include respond-to('md') {
justify-content: center;
gap: 9px;
}
@include respond-to('sm') {
// Figma 모바일: 7px 간격, wrap
gap: 7px;
justify-content: left;
}
}
.hashtag-link {
font-family: $font-family-primary;
font-size: 14px;
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: 13px;
}
@include respond-to('sm') {
// Figma 모바일: 11px
font-size: 11px;
color: #212529;
}
}
.hashtag-separator {
font-family: $font-family-primary;
font-size: 14px;
color: #000000;
user-select: none;
@include respond-to('md') {
font-size: 13px;
}
@include respond-to('sm') {
// Figma 모바일: 세로 구분선 (|)
font-size: 9px;
color: #212529;
display: flex;
align-items: center;
height: 9px;
}
}
}
*/
/* 피그마 시안 적용 카드 레이아웃 스타일 */
.search-container {
@@ -1288,14 +952,21 @@
.card-description {
font-family: $font-family-primary;
font-size: 13px;
font-size: 16px;
color: var(--text-gray);
line-height: 1.5;
text-align: left;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.5;
max-height: 3em;
@include respond-to('sm') {
// Figma 모바일: 11px Medium, 색상 #515151
font-size: 13px;
font-size: 14px;
font-weight: 500;
line-height: 1;
@@ -1425,7 +1096,7 @@
letter-spacing: 1px;
@include respond-to('sm') {
font-size: 13px;
font-size: 14px;
}
}
@@ -1447,7 +1118,7 @@
}
.info-description {
font-size: 14px;
font-size: 16px;
line-height: 1.7;
color: rgba(255, 255, 255, 0.75);
margin-bottom: 36px;
@@ -1459,7 +1130,7 @@
@include respond-to('sm') {
order: 3;
font-size: 13px;
font-size: 15px;
line-height: 1.6;
margin-bottom: 24px;
word-break: keep-all;
@@ -1637,424 +1308,6 @@
// -----------------------------------------------------------------------------
// Support Center Section - 비지니스의 시작 (Figma Design)
// -----------------------------------------------------------------------------
/* 원래 있던 support-center 스타일 (주석 처리)
.support-center {
position: relative;
padding: 0;
padding-top: 92px;
padding-bottom: 113px;
background: #eef7fd;
overflow: hidden;
height: 648px;
@include respond-to('md') {
height: auto;
padding-bottom: 60px;
}
@include respond-to('sm') {
// Figma 모바일: 353px 높이, 패딩 54px 18px
min-height: unset;
height: auto;
padding: 54px 18px;
}
.support-background {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 100%;
height: 100%;
max-width: 1920px;
pointer-events: none;
&::before {
content: '';
position: absolute;
top: 15px; // Figma: top-[2911px] relative to page, section starts at ~2895px
left: 46%; // Approximate positioning
width: 624px;
height: 537px;
background-image: url('/img/bg_support_center.png'); // Assuming this image exists or use placeholder
background-size: cover;
background-repeat: no-repeat;
opacity: 0.5; // Adjust based on visual
}
@include respond-to('sm') {
// Figma 모바일: 포인트 이미지 상단 우측
&::before {
top: 0;
left: auto;
right: 0;
width: 168px;
height: 159px;
opacity: 1;
}
}
}
.container {
position: relative;
z-index: 1;
max-width: 1280px;
margin: 0 auto;
padding: 0 23px;
height: 443px;
@include respond-to('md') {
height: auto;
}
@include respond-to('sm') {
padding: 0;
max-width: 100%;
width: 100%;
height: auto;
}
}
.section-header {
margin-bottom: 36px;
text-align: center;
@include respond-to('sm') {
// Figma 모바일: 타이틀 영역
margin-bottom: 22px;
text-align: left;
}
.section-title {
font-size: 25px;
line-height: 1.4;
color: #000000;
margin: 0 0 $spacing-lg 0;
text-align: left;
@include respond-to('md') {
font-size: 32px;
line-height: 1.4;
}
@include respond-to('sm') {
// Figma 모바일: 타이틀 스타일
font-size: 14px;
line-height: 1;
letter-spacing: -0.32px;
}
.title-regular {
@include respond-to('sm') {
font-weight: 500;
}
}
.title-bold {
font-size: 34px;
font-weight: 700;
@include respond-to('sm') {
// Figma 모바일: 18px Bold
font-size: 18px;
letter-spacing: -0.4px;
}
}
}
}
.support-grid {
display: flex;
gap: 29px;
justify-content: center;
align-items: stretch;
max-width: 1228px; // 598 + 283 + 283 + (32 * 2) = 1228px
height: auto;
min-height: 288px;
margin: 0 auto;
flex-wrap: wrap;
@include respond-to('mobile') {
flex-direction: column;
align-items: center;
gap: 18px;
height: auto;
}
@include respond-to('sm') {
// 가로 배치는 카드가 너무 좁아지므로 1열 세로 배치로 변경
flex-direction: column;
align-items: stretch;
gap: 14px;
max-width: 100%;
height: auto;
}
}
// 통합 Support Card
.support-card {
background: #FFFFFF;
border-radius: 18px;
text-decoration: none;
transition: all 0.3s ease;
display: flex;
box-sizing: border-box;
position: relative;
overflow: hidden;
height: 288px; // 모든 카드 동일 높이
@include respond-to('mobile') {
width: 100%;
max-width: 598px;
height: auto;
min-height: 234px;
}
@include respond-to('sm') {
// Figma 모바일: 101x168px 카드
width: calc(33.333% - 7px);
min-width: 0;
height: 151px;
min-height: 151px;
max-width: none;
border-radius: 11px;
padding: 23px 0 23px;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 14px;
}
&:hover {
transform: translateY(-5px);
box-shadow: 0 9px 27px rgba(0, 73, 180, 0.15);
@include respond-to('sm') {
transform: translateY(-2px);
}
}
.card-icon {
display: flex;
align-items: center;
justify-content: center;
img {
width: 100%;
height: 100%;
object-fit: contain;
}
@include respond-to('sm') {
// Figma 모바일: 43px 아이콘
width: 43px !important;
height: 43px !important;
margin-bottom: 0 !important;
}
}
.card-content {
display: flex;
flex-direction: column;
justify-content: center;
@include respond-to('sm') {
align-items: center;
text-align: center;
gap: 4px;
}
h3 {
font-family: $font-family-primary;
font-size: 29px;
font-weight: 700;
color: #212529;
margin: 0 0 9px 0;
line-height: 1.3;
@include respond-to('md') {
font-size: 25px;
}
@include respond-to('sm') {
// Figma 모바일: 15-14px Bold
font-size: 14px;
margin: 0;
line-height: 1;
}
}
p {
font-family: $font-family-primary;
font-size: 16px;
font-weight: 400;
color: #515961;
margin: 0;
line-height: 1.4;
@include respond-to('md') {
font-size: 14px;
}
@include respond-to('sm') {
// Figma 모바일: 9px Regular
font-size: 9px;
line-height: 1;
}
}
}
// 공지사항 카드
&-notice {
width: 349px;
flex-shrink: 0;
flex-direction: column;
align-items: center;
padding: 45px 36px;
background: #0049B4;
@include respond-to('mobile') {
width: 100%;
max-width: 598px;
padding: 45px 36px;
}
@include respond-to('sm') {
// Figma 모바일: 공지사항 카드 스타일
width: 100%;
padding: 23px 20px 23px;
gap: 17px;
height: auto;
}
.card-icon {
width: 108px;
height: 108px;
margin-bottom: 32px;
}
.card-content {
align-items: center;
text-align: center;
h3 {
color: #FFFFFF;
font-size: 27px;
@include respond-to('md') {
font-size: 29px;
}
@include respond-to('sm') {
font-size: 14px;
color: #FFFFFF;
}
}
p {
color: rgba(255, 255, 255, 0.9);
font-size: 20px;
@include respond-to('md') {
font-size: 16px;
}
@include respond-to('sm') {
font-size: 9px;
color: rgba(255, 255, 255, 0.9);
}
}
}
&:hover {
background: darken(#0049B4, 5%);
}
}
// FAQ & Q&A Cards
&-faq,
&-qna {
width: 349px;
flex-shrink: 0;
flex-direction: column;
align-items: center;
padding: 45px 36px;
@include respond-to('mobile') {
width: 100%;
max-width: 598px;
padding: 36px;
}
@include respond-to('sm') {
// Figma 모바일: FAQ/Q&A 카드 스타일
width: 100%;
padding: 23px 20px 23px;
height: auto;
}
.card-icon {
width: 108px;
height: 108px;
margin-bottom: 32px;
}
.card-content {
align-items: center;
text-align: center;
flex: none;
h3 {
font-size: 27px;
@include respond-to('md') {
font-size: 29px;
}
@include respond-to('sm') {
// Figma 모바일: 14px Bold
font-size: 14px;
}
}
p {
font-size: 20px;
@include respond-to('md') {
font-size: 16px;
}
@include respond-to('sm') {
// Figma 모바일: 9px Regular
font-size: 9px;
}
}
}
}
// FAQ Card Specifics
&--faq {
background: #DAF0FF;
.card-content h3 {
color: #212529;
}
}
// Q&A Card Specifics
&--qna {
background: #D9F6F8;
.card-content h3 {
color: #212529;
}
}
}
}
*/
/* 피그마 시안 적용 Support Center 그리드 레이아웃 스타일 */
.support-center {
@@ -2130,9 +1383,10 @@
}
p {
font-size: 13px;
font-size: 14px;
color: var(--text-gray);
line-height: 1.5;
width: 80%;
}
.card-arrow {
@@ -120,7 +120,7 @@
}
.form-label-text {
font-size: 16px;
font-size: 14px;
font-weight: 700;
color: #000;
}
@@ -159,7 +159,7 @@
.form-input:disabled,
.input-readonly {
background: #e9ecef !important;
background: #ededed !important;
}
.compound-input {
@@ -289,6 +289,7 @@
width: 24px;
height: 24px;
flex-shrink: 0;
color: #4685ef
}
p {
@@ -136,9 +136,9 @@
}
&--completed {
background: #e5f0ff;
color: #0a4ea3;
border: 1px solid #b3d4ff;
background: #d6fbd0;
color: #11af4c;
border: 1px solid #a0d4b2;
}
&--closed {
@@ -159,6 +159,12 @@
border: 1px solid #b3d4ff;
}
&--reviewing {
background: #e5f0ff;
color: #0a4ea3;
border: 1px solid #b3d4ff;
}
&--test {
background: #e6fffa;
color: #0d9488;
@@ -352,6 +358,14 @@
font-weight: 700;
flex-shrink: 0;
}
// 개발자포탈에 게시되지 않은 GW 인터페이스 묶음 ("GW 인터페이스 3건")
&.is-gw {
background-color: #ffffff;
border-style: dashed;
border-color: #cbd5e1;
color: #6b7280;
}
}
.btn-affected-toggle {
@@ -443,6 +457,96 @@
}
}
// 장애 처리 타임라인 상단 장애정보 표와 같은 규격의 간결한
.notice-detail-timeline {
padding-bottom: 40px;
border-bottom: 1px solid #eee;
margin-bottom: $spacing-3xl;
.notice-timeline-title {
margin: 0 0 16px;
font-size: 18px;
font-weight: 700;
color: #000;
}
.timeline-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
border-bottom: 1px solid #e3e8f0;
thead th {
background: #f9f9f9;
height: 43px;
font-weight: 500;
font-size: 14px;
text-align: center;
color: #000;
border-bottom: 1px solid #e3e8f0;
}
tbody tr {
border-bottom: 1px solid #e3e8f0;
}
td {
padding: 10px 16px;
font-size: 14px;
color: #000;
vertical-align: top;
}
.timeline-ts {
color: #555;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.timeline-body {
line-height: 1.6;
white-space: pre-line;
word-break: break-all;
}
@media (max-width: $breakpoint-md) {
table-layout: auto;
colgroup {
display: none;
}
thead th,
td {
padding: 8px 10px;
font-size: 13px;
}
.timeline-ts {
white-space: normal;
}
}
}
// 상태 배지 API Status 타임라인 dot 규격과 동일
.timeline-state {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
background: #eef1f5;
color: #4b5563;
&.state-RESOLVED { background: #e7f6ec; color: #1b8c4a; }
&.state-MONITORING,
&.state-INVESTIGATING { background: #fef3c7; color: #c77800; }
&.state-IDENTIFIED { background: #fdeee2; color: #c95a0f; }
&.state-CANCELED { background: #eef1f5; color: #6b7280; }
}
}
// Notice Detail Actions
.notice-detail-actions {
display: flex;
@@ -34,6 +34,53 @@
@media (max-width: $breakpoint-sm) {
padding: $spacing-lg $spacing-md;
}
.invitation_alert_banner {
background-color: #f0f8ff;
border: 1.5px solid #2196F3;
border-radius: 12px;
padding: 24px;
margin: 20px 0;
box-shadow: 0 8px 24px rgba(33, 150, 243, 0.08);
animation: fadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) both;
transform: translateY(15px);
opacity: 0;
margin-bottom: 60px;
.banner-content-wrapper {
display: flex;
align-items: center;
gap: 16px;
}
.banner-icon-area {
display: flex;
align-items: center;
justify-content: center;
svg {
animation: pulseIcon 2s infinite ease-in-out;
}
}
.banner-text-area {
strong {
color: #1976D2;
font-size: 17px;
display: block;
margin-bottom: 4px;
}
p {
margin: 0;
color: #475569;
font-size: 14px;
line-height: 1.5;
display: flex;
align-items: center;
}
}
}
}
// Header
@@ -315,6 +362,16 @@
background-position: right 8px center;
}
.org-form-select.phone-prefix {
flex: 1;
min-width: 90px;
max-width: 130px;
padding-right: 28px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2364748B' d='M6 9L1 4h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
}
.separator {
color: $text-gray;
font-weight: $font-weight-semibold;
@@ -336,29 +393,31 @@
// Select Dropdown
.org-form-select {
width: 100%;
padding: 8px 12px;
border: 1px solid $border-gray;
border-radius: $border-radius-md;
height: 48px;
padding: 0 16px;
border: 1px solid #E2E8F0;
border-radius: 10px;
font-size: 14px;
color: $text-dark;
color: #212529;
background: $white;
cursor: pointer;
transition: $transition-base;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2364748B' d='M6 9L1 4h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right $spacing-lg center;
background-position: right 16px center;
padding-right: $spacing-3xl;
&:focus {
outline: none;
border-color: $primary-blue;
box-shadow: 0 0 0 3px rgba(75, 155, 255, 0.1);
border-color: #3ba4ed;
box-shadow: 0 0 0 3px rgba(59, 164, 237, 0.12);
}
&:disabled {
background-color: $gray-bg;
color: $text-light;
background-color: #F8F9FA;
border-color: #E2E8F0;
color: #94A3B8;
cursor: not-allowed;
}
}
+402 -181
View File
@@ -386,26 +386,31 @@ $service-card-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgb
padding-bottom: 100px;
.signup-info-box {
background: #f5f5f5;
border-radius: 12px;
padding: 30px 40px;
background: #1d2a5e; // Deep slate dark card
border: 1px solid rgba(255, 255, 255, 0.08);
border-left: 5px solid #2a69de; // Bright tech blue accent line
border-radius: 16px;
padding: 28px 32px;
display: flex;
align-items: center;
gap: 30px;
gap: 28px;
animation: fadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) both;
&__icon {
width: 76px;
height: 76px;
background: #fff;
border-radius: 50%;
width: 56px;
height: 56px;
background: #48507b;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
img {
width: 40px;
height: 40px;
width: 28px;
height: 28px;
filter: brightness(0) invert(1); // Turns the green check into clean white icon
object-fit: contain;
}
}
@@ -417,183 +422,397 @@ $service-card-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgb
li {
position: relative;
padding-left: 14px;
color: #535151;
font-size: 16px;
padding-left: 20px;
color: #94a3b8; // Sophisticated dim gray-blue
font-size: 15px;
font-weight: 500;
line-height: 1.8;
word-break: keep-all;
strong {
color: #ffffff;
font-weight: 700;
}
&::before {
content: '·';
content: '';
position: absolute;
left: 0;
color: #535151;
left: 4px;
top: 10px;
width: 5px;
height: 5px;
background: #38bdf8; // Neon cyan dot
border-radius: 50%;
}
&+li {
margin-top: 6px;
}
}
}
}
}
.signup-timeline {
// Member Type Tabs
.signup-tabs {
display: flex;
justify-content: flex-start;
border-bottom: 1px solid #e3e8f0;
margin-top: 40px;
margin-bottom: 50px;
width: 100%;
.signup-tab-btn {
flex: 1;
max-width: 50%;
padding: 16px 20px;
font-size: 16px;
font-weight: 500;
color: #94a3b8;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
text-align: center;
transition: all 0.2s ease;
outline: none;
&:hover {
color: #1e293b;
}
&.active {
color: #2a69de;
font-weight: 700;
border-bottom: 2px solid #2a69de;
background: #ffffff;
}
}
}
.signup-timeline-container {
margin-bottom: 50px;
min-height: 300px;
}
.signup-timeline {
display: none; // Hidden by default, toggled via JS
flex-direction: column;
gap: 40px;
position: relative;
padding-left: 80px;
&.active {
display: flex;
}
&::before {
content: '';
position: absolute;
left: 17px; // center of line
top: 47px;
bottom: 47px;
width: 2px;
background: #e6eefb; // Light blue line
z-index: 0;
}
}
.signup-step {
display: flex;
align-items: center;
gap: 40px;
position: relative;
z-index: 1;
// Glowing Dot
&::before {
content: '';
position: absolute;
left: -71px; // (80 - 17) = 63 distance to line center. 63 + 8 (half of 16px) = 71px
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
background: #2a69de;
border-radius: 50%;
box-shadow: 0 0 0 6px rgba(42, 105, 222, 0.15), 0 0 10px 6px rgba(42, 105, 222, 0.1);
z-index: 2;
}
&__icon-box {
width: 95px;
min-height: 95px;
background: #fff;
border: 1px solid #e3e8f0;
border-radius: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 4px 4px rgba(173, 173, 173, 0.1);
svg {
width: 30px;
height: 30px;
}
}
&__card {
flex: 1;
background: #fff;
border: 1px solid #e3e8f0;
border-radius: 20px;
padding: 20px 30px;
box-shadow: 0 4px 4px rgba(173, 173, 173, 0.1);
display: flex;
flex-direction: column;
gap: 40px;
position: relative;
padding-left: 80px;
justify-content: center;
min-height: 95px;
&::before {
content: '';
position: absolute;
left: 17px; // center of line
top: 47px;
bottom: 47px;
width: 2px;
background: #e6eefb; // Light blue line
z-index: 0;
&-header {
display: flex;
align-items: center;
margin-bottom: 6px;
}
&-num {
font-size: 14px;
font-weight: 700;
color: #1b4ab7;
}
&-dot {
width: 4px;
height: 4px;
background: #1b4ab7;
border-radius: 50%;
margin: 0 8px;
}
&-title {
font-size: 18px;
font-weight: 700;
color: #000;
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
&-desc {
margin: 0;
font-size: 14px;
color: #64748b;
line-height: 1.5;
}
}
.signup-step {
.one {
color: #2C4A8C;
}
.two {
color: #1F7A5C;
}
.three {
color: #F98E24;
}
.four {
color: #2480F9;
}
.five {
color: #FF5C5C;
}
.six {
color: #5B4AEE;
}
}
.signup-action {
display: flex;
justify-content: center;
margin-top: 20px;
}
// 권한별 이용 가능 서비스 (TBD placeholder)
.signup-roles-tbd {
margin-top: 40px;
&__title {
font-size: 20px;
font-weight: 700;
color: #140064;
margin: 0 0 16px;
}
&__placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 40px;
justify-content: center;
gap: 12px;
padding: 48px 24px;
background: #f8f9fa;
border: 1px dashed #e3e8f0;
border-radius: 12px;
text-align: center;
}
&__badge {
display: inline-block;
padding: 4px 12px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
color: #0049B4;
background: #eff9fe;
border-radius: 20px;
}
&__desc {
margin: 0;
font-size: 15px;
color: #6e7781;
}
}
// 권한별 이용 가능 서비스 테이블 디자인 추가
.signup-roles-table-section {
margin-top: 50px;
margin-bottom: 20px;
&__title {
font-size: 20px;
font-weight: 700;
color: #1e293b;
margin: 0 0 20px;
position: relative;
z-index: 1;
padding-left: 12px;
margin-bottom: 30px;
// Glowing Dot
&::before {
content: '';
position: absolute;
left: -71px; // (80 - 17) = 63 distance to line center. 63 + 8 (half of 16px) = 71px
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
background: #2a69de;
border-radius: 50%;
box-shadow: 0 0 0 6px rgba(42, 105, 222, 0.15), 0 0 10px 6px rgba(42, 105, 222, 0.1);
z-index: 2;
left: 0;
top: 4px;
bottom: 4px;
width: 4px;
background: #0049B4;
border-radius: 2px;
}
}
.signup-roles-table-wrapper {
width: 100%;
overflow-x: auto;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #ffffff;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.02);
&::-webkit-scrollbar {
height: 6px;
}
&__icon-box {
width: 95px;
height: 95px;
background: #fff;
border: 1px solid #e3e8f0;
border-radius: 20px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 4px 4px rgba(173, 173, 173, 0.1);
&::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
}
svg {
width: 30px;
height: 30px;
.signup-roles-table {
width: 100%;
border-collapse: collapse;
font-family: $font-family-primary;
text-align: center;
th,
td {
padding: 14px 16px;
font-size: 14px;
border-bottom: 1px solid #e2e8f0;
border-right: 1px solid #e2e8f0;
&:last-child {
border-right: none;
}
}
&__card {
flex: 1;
background: #fff;
border: 1px solid #e3e8f0;
border-radius: 20px;
padding: 0 30px;
box-shadow: 0 4px 4px rgba(173, 173, 173, 0.1);
display: flex;
flex-direction: column;
justify-content: center;
height: 95px;
thead {
background: #f8fafc;
&-num {
th {
font-weight: 700;
color: #334155;
font-size: 13px;
&.col-feature {
width: 35%;
font-size: 14px;
color: #1e293b;
}
&.col-role {
width: 15%;
}
&.col-role-group {
background: #f1f5f9;
color: #0f172a;
}
&.col-sub-role {
width: 17.5%;
background: #f8fafc;
}
}
}
tbody {
tr {
transition: background 0.2s ease;
&:hover {
background: #f8fafc;
}
&:last-child td {
border-bottom: none;
}
}
.cell-feature {
font-weight: 600;
color: #475569;
text-align: left;
padding-left: 20px;
}
// O / X 배지 디자인
.status-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
font-size: 14px;
font-weight: 700;
color: #1b4ab7;
margin-bottom: 4px;
&--allowed {
background: #e0f2fe;
color: #0369a1;
}
&--denied {
background: #f1f5f9;
color: #94a3b8;
}
}
&-title {
font-size: 18px;
font-weight: 500;
color: #000;
margin: 0;
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
}
.one {
color: #2C4A8C;
}
.two {
color: #1F7A5C;
}
.three {
color: #F98E24;
}
.four {
color: #2480F9;
}
.five {
color: #FF5C5C;
}
.six {
color: #5B4AEE;
}
}
.signup-action {
display: flex;
justify-content: center;
margin-top: 20px;
}
// 권한별 이용 가능 서비스 (TBD placeholder)
.signup-roles-tbd {
margin-top: 40px;
&__title {
font-size: 20px;
font-weight: 700;
color: #140064;
margin: 0 0 16px;
}
&__placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 48px 24px;
background: #f8f9fa;
border: 1px dashed #e3e8f0;
border-radius: 12px;
text-align: center;
}
&__badge {
display: inline-block;
padding: 4px 12px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
color: #0049B4;
background: #eff9fe;
border-radius: 20px;
}
&__desc {
margin: 0;
font-size: 15px;
color: #6e7781;
}
}
}
@@ -630,12 +849,25 @@ $service-card-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgb
@media (max-width: 768px) {
.signup-guide-v2 {
.signup-timeline {
padding-left: 0;
&::before {
display: none; // Hides the vertical timeline line
}
}
.signup-step {
gap: 15px;
&::before {
display: none; // Hides the glowing timeline dot
}
&__icon-box {
width: 60px;
height: 60px;
min-height: 60px;
svg {
width: 24px;
@@ -646,25 +878,13 @@ $service-card-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgb
&__card {
height: auto;
min-height: 60px;
padding: 10px 15px;
padding: 12px 18px;
&-title {
font-size: 15px;
}
}
}
.signup-timeline {
padding-left: 50px;
&::before {
left: 17px;
}
}
.signup-step::before {
left: -41px; // adjusted for new padding
}
}
}
@@ -676,23 +896,23 @@ $service-card-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgb
padding: 56px 16px 32px;
text-align: center;
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
}
.service_guide {
.title {
font-size: 28px;
font-weight: 700;
line-height: 36px;
letter-spacing: -0.02em;
color: #140064;
margin-bottom: 16px;
}
.service_guide {
.title {
font-size: 28px;
font-weight: 700;
line-height: 36px;
letter-spacing: -0.02em;
color: #140064;
margin-bottom: 16px;
}
.detail {
font-size: 14px;
font-weight: 400;
line-height: 22px;
color: #495057;
}
.detail {
font-size: 14px;
font-weight: 400;
line-height: 22px;
color: #495057;
}
}
@@ -1117,9 +1337,10 @@ $o2leg-err-fg: #a23b3b;
&__prereq-title {
margin: 4px 0 8px;
font-size: 15px;
font-size: 16px;
font-weight: 700;
color: $o2leg-text-dark;
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
&__prereq-body p {
@@ -63,6 +63,7 @@ $wh-bg-soft: #f9f9f9;
border-radius: 50%;
margin-bottom: 16px;
color: #cbd5e1;
font-size: 25px;
}
h3 {
@@ -704,7 +705,7 @@ $wh-bg-soft: #f9f9f9;
.input-display-box {
width: 100%;
height: 48px;
background-color: #eeeeee;
background-color: #efefef;
border-radius: 10px;
padding: 0 20px;
display: flex;
@@ -714,6 +715,7 @@ $wh-bg-soft: #f9f9f9;
font-weight: 500;
word-break: break-all;
box-sizing: border-box;
border: 1px solid #DFDFDF;
}
.badges-row {
@@ -767,7 +769,7 @@ $wh-bg-soft: #f9f9f9;
flex: 1;
max-width: 250px;
height: 48px;
background-color: #eeeeee;
background-color: #efefef;
border-radius: 10px;
padding: 0 20px;
display: flex;
@@ -778,6 +780,7 @@ $wh-bg-soft: #f9f9f9;
font-weight: 500;
box-sizing: border-box;
letter-spacing: 1px;
border: 1px solid #DFDFDF;
@media (max-width: 576px) {
max-width: 100%;
@@ -8,7 +8,7 @@
<section class="service-hero">
<div class="service-hero__inner">
<div class="service-hero__icon-wrapper">
<img th:src="@{/img/keyimage/api_img.svg}" alt="OPEN API 3D 아이콘" class="service-keyImg" />
<img th:src="@{/img/keyimage/api_img.png}" alt="OPEN API 3D 아이콘" class="service-keyImg" />
</div>
<div class="service-hero__content">
<div class="service-hero__badge">
@@ -1,61 +1,64 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/djbank_title_layout}">
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/djbank_title_layout}">
<body>
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>본인 확인</h1>
</div>
</section>
<section layout:fragment="contentFragment">
<div class="service-main container" style="padding-top: 70px; padding-bottom:100px;">
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('profile')}"></th:block>
<div class="app-management-content">
<div class="password-change-wrapper">
<h2 class="page-outer-title">본인 확인</h2>
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>본인 확인</h1>
</div>
</section>
<section layout:fragment="contentFragment">
<div class="service-main container" style="padding-top: 70px; padding-bottom:100px;">
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('profile')}"></th:block>
<div class="app-management-content">
<div class="password-change-wrapper">
<h2 class="page-outer-title">본인 확인</h2>
<form th:action="@{/auth/stepup/password}" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<input type="hidden" name="returnUrl" th:value="${returnUrl}"/>
<form th:action="@{/auth/stepup/password}" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<input type="hidden" name="returnUrl" th:value="${returnUrl}" />
<div class="register-form-container">
<div class="info-notice-box">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4685ef" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<path d="M12 16v-4"></path>
<path d="M12 8h.01"></path>
</svg>
<p>내 정보 보호를 위하여 현재 비밀번호를 다시 입력해 주세요</p>
</div>
<div class="form-row">
<div class="form-label-wrapper">
<span class="form-label-text">현재 비밀번호</span>
<div class="register-form-container">
<div class="info-notice-box">
<svg class="notice-icon" width="20" height="20" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<p>내 정보 보호를 위하여 현재 비밀번호를 다시 입력해 주세요</p>
</div>
<div class="form-field-wrapper">
<input type="password" name="currentPassword" class="form-input"
placeholder="비밀번호 입력" required autofocus>
<div class="form-row">
<div class="form-label-wrapper">
<span class="form-label-text">현재 비밀번호</span>
</div>
<div class="form-field-wrapper">
<input type="password" name="currentPassword" class="form-input"
placeholder="비밀번호 입력" required autofocus>
</div>
</div>
</div>
</div>
<div class="form-actions" style="justify-content: flex-end;">
<div class="right-buttons">
<button type="button" class="btn-cancel" th:onclick="|location.href='@{/}'|">취소</button>
<button type="submit" class="btn-apply btn-primary">확인</button>
<div class="form-actions" style="justify-content: flex-end;">
<div class="right-buttons">
<button type="button" class="btn-cancel" th:onclick="|location.href='@{/}'|">취소</button>
<button type="submit" class="btn-apply btn-primary">확인</button>
</div>
</div>
</div>
</form>
</form>
</div>
</div>
</div>
</div>
<script th:if="${error}" th:inline="javascript">
$(document).ready(function () {
customPopups.showAlert([[${error}]]);
})
</script>
</section>
<script th:if="${error}" th:inline="javascript">
$(document).ready(function () {
customPopups.showAlert([[${ error }]]);
})
</script>
</section>
</body>
</html>
</html>
@@ -16,7 +16,7 @@
<section class="service-hero">
<div class="service-hero__inner">
<div class="service-hero__icon-wrapper">
<img th:src="@{/img/keyimage/faq_img.svg}" alt="FAQ 아이콘"
<img th:src="@{/img/keyimage/faq_img.png}" alt="FAQ 아이콘"
style="width: 100%; height: 100%; object-fit: contain;" />
</div>
<div class="service-hero__content">
@@ -17,7 +17,7 @@
<div class="service-hero__inner">
<div class="service-hero__icon-wrapper">
<!-- TODO: Update icon to faq icon -->
<img th:src="@{/img/keyimage/faq_img.svg}" alt="FAQ 아이콘"
<img th:src="@{/img/keyimage/faq_img.png}" alt="FAQ 아이콘"
style="width: 100%; height: 100%; object-fit: contain;" />
</div>
<div class="service-hero__content">
@@ -76,9 +76,9 @@
<!-- Table Header -->
<div class="board-table-header">
<div class="header-cell" style="width: 80px;">NO</div>
<div class="header-cell" style="width: 120px;">처리상태</div>
<div class="header-cell" style="flex: 1; min-width: 200px;">제목</div>
<div class="header-cell" style="width: 120px;">작성자</div>
<div class="header-cell" style="width: 120px;">처리상태</div>
<div class="header-cell" style="width: 80px;">조회수</div>
<div class="header-cell" style="width: 120px;">등록일</div>
</div>
@@ -89,9 +89,19 @@
th:classappend="${inquiry.privatePlaceholder} ? ' board-table-row--private'"
th:onclick="${inquiry.privatePlaceholder} ? null : ('location.href=\'' + @{/inquiry/detail(id=${inquiry.id})} + '\'')"
th:style="${inquiry.privatePlaceholder} ? 'cursor: default;' : 'cursor: pointer;'">
<div class="row-cell row-cell--number" data-label="NO"
<div class="row-cell row-cell--number" style="width: 80px;" data-label="NO"
th:text="${page.totalElements - (page.number * page.size) - status.index}">1</div>
<div class="row-cell row-cell--title" data-label="제목">
<!-- 처리상태 (Moved here) -->
<div class="row-cell row-cell--status" style="width: 120px;" data-label="처리상태">
<span class="notice-type-badge"
th:classappend="${inquiry.inquiryStatus == 'RESPONDED' ? 'notice-type-badge--completed' : (inquiry.inquiryStatus == 'CLOSED' ? 'notice-type-badge--closed' : (inquiry.inquiryStatus == 'REVIEWING' ? 'notice-type-badge--reviewing' : 'notice-type-badge--pending'))}"
th:text="${T(com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus).displayName(inquiry.inquiryStatus)}">
답변대기
</span>
</div>
<div class="row-cell row-cell--title" style="flex: 1; min-width: 200px;" data-label="제목">
<a th:href="${inquiry.privatePlaceholder} ? null : @{/inquiry/detail(id=${inquiry.id})}"
class="notice-title-link"
th:classappend="${inquiry.privatePlaceholder} ? ' notice-title-link--disabled'">
@@ -99,7 +109,7 @@
th:text="|[${page.totalElements - (page.number * page.size) - status.index}]|">[1]</span>
<!-- Private/Lock Icon -->
<span class="file-icon" th:if="${inquiry.visibility == 'PRIVATE'}">
<span class="file-icon" th:if="${inquiry.visibility == 'PRIVATE'}" style="margin-right: 4px;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"
aria-label="비공개">
<rect x="4" y="10" width="16" height="11" rx="2" fill="currentColor" />
@@ -117,22 +127,14 @@
th:text="|[${commentCounts.get(inquiry.id)}]|">[3]</span>
</a>
</div>
<div class="row-cell row-cell--writer" data-label="작성자">
<div class="row-cell row-cell--writer" style="width: 120px;" data-label="작성자">
<span class="inquiry-writer-name" th:text="${inquiry.maskedInquirerName}">홍**</span>
<span class="inquiry-writer-org"
th:if="${inquiry.inquirerOrgName != null and !inquiry.inquirerOrgName.isEmpty()}"
th:text="|(${inquiry.inquirerOrgName})|">(법인명)</span>
</div>
<div class="row-cell row-cell--status" data-label="처리상태">
<!-- completed for RESPONDED, closed for CLOSED, pending for PENDING, maintenance for REVIEWING -->
<span class="notice-type-badge lg"
th:classappend="${inquiry.inquiryStatus == 'RESPONDED' ? 'notice-type-badge--completed' : (inquiry.inquiryStatus == 'CLOSED' ? 'notice-type-badge--closed' : (inquiry.inquiryStatus == 'REVIEWING' ? 'notice-type-badge--maintenance' : 'notice-type-badge--pending'))}"
th:text="${T(com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus).displayName(inquiry.inquiryStatus)}">
답변대기
</span>
</div>
<div class="row-cell row-cell--views" data-label="조회수" th:text="${inquiry.viewCount}">0</div>
<div class="row-cell row-cell--date" data-label="등록일"
<div class="row-cell row-cell--views" style="width: 80px;" data-label="조회수" th:text="${inquiry.viewCount}">0</div>
<div class="row-cell row-cell--date" style="width: 120px;" data-label="등록일"
th:text="${#temporals.format(inquiry.createdDate, 'yyyy.MM.dd')}">2025.01.01</div>
</div>
</div>
@@ -16,7 +16,7 @@
<section class="service-hero">
<div class="service-hero__inner">
<div class="service-hero__icon-wrapper">
<img th:src="@{/img/keyimage/notice_img.svg}" alt="공지사항 아이콘"
<img th:src="@{/img/keyimage/notice_img.png}" alt="공지사항 아이콘"
style="width: 100%; height: 100%; object-fit: contain;" />
</div>
<div class="service-hero__content">
@@ -69,7 +69,7 @@
<td
th:text="${portalNotice.startedAt != null ? #temporals.format(portalNotice.startedAt, 'yyyy-MM-dd HH:mm') : '-'}">
-</td>
<th></th>
<th>종료</th>
<td
th:text="${portalNotice.endAt != null ? #temporals.format(portalNotice.endAt, 'yyyy-MM-dd HH:mm') : '진행중'}">
-</td>
@@ -78,29 +78,27 @@
<th>상태</th>
<td colspan="3" th:text="${portalNotice.state != null ? portalNotice.state : '-'}">-</td>
</tr>
<tr th:if="${portalNotice.affectedApis != null and !portalNotice.affectedApis.isEmpty()}">
<tr
th:if="${(portalNotice.affectedApis != null and !portalNotice.affectedApis.isEmpty()) or portalNotice.hiddenApiCount > 0}">
<th>영향 API</th>
<td colspan="3">
<div class="affected-apis-wrapper">
<!-- First 3 APIs (Always visible) -->
<!-- 개발자포탈에 게시된 API 만 개별 노출한다 (인터페이스 ID 는 표시하지 않음) -->
<div class="affected-apis-list">
<span class="affected-api-badge" th:each="api, iterStat : ${portalNotice.affectedApis}"
th:if="${iterStat.index < 3}">
<strong th:text="${api.apiId}">API_ID</strong><span
th:if="${api.apiName != null and !api.apiName.isEmpty()}"
th:text="| - ${api.apiName}|"></span>
</span>
th:if="${iterStat.index < 3}" th:text="${api.apiName}">API 명</span>
<!-- Rest of APIs (Hidden by default) -->
<th:block th:if="${portalNotice.affectedApis.size() > 3}">
<span class="affected-api-badge extra-api"
th:each="api, iterStat : ${portalNotice.affectedApis}" th:if="${iterStat.index >= 3}"
style="display: none;">
<strong th:text="${api.apiId}">API_ID</strong><span
th:if="${api.apiName != null and !api.apiName.isEmpty()}"
th:text="| - ${api.apiName}|"></span>
</span>
style="display: none;" th:text="${api.apiName}">API 명</span>
</th:block>
<!-- 게시되지 않은 GW 인터페이스는 건수로만 묶어서 표기 -->
<span class="affected-api-badge is-gw" th:if="${portalNotice.hiddenApiCount > 0}"
th:text="|GW 인터페이스 ${portalNotice.hiddenApiCount}건|"
title="개발자포탈에 게시되지 않은 게이트웨이 인터페이스입니다">GW 인터페이스 0건</span>
</div>
<!-- Toggle Button -->
@@ -141,6 +139,38 @@
</div>
</div>
<!-- 장애 처리 타임라인 (공개 항목이 있을 때만) -->
<div class="notice-detail-timeline" th:if="${portalNotice.hasTimeline()}">
<h3 class="notice-timeline-title">처리 경과</h3>
<table class="detail-table timeline-table">
<colgroup>
<col style="width: 160px;">
<col style="width: 110px;">
<col>
</colgroup>
<thead>
<tr>
<th>일시</th>
<th>상태</th>
<th>내용</th>
</tr>
</thead>
<tbody>
<tr th:each="entry : ${portalNotice.timeline}">
<td class="timeline-ts"
th:text="${entry.eventAt != null ? #temporals.format(entry.eventAt, 'yyyy-MM-dd HH:mm') : '-'}">
2026-08-04 11:44</td>
<td>
<span class="timeline-state"
th:classappend="${'state-' + (entry.stateAfter != null ? entry.stateAfter : 'NONE')}"
th:text="${entry.labelKo != null ? entry.labelKo : '진행 상황'}">원인 확인</span>
</td>
<td class="timeline-body" th:text="${entry.body}">진행 내용</td>
</tr>
</tbody>
</table>
</div>
<!-- Action Buttons -->
<div class="notice-detail-actions">
<button type="button" class="btn-action-primary btn-notice-list"
@@ -16,7 +16,7 @@
<section class="service-hero">
<div class="service-hero__inner">
<div class="service-hero__icon-wrapper">
<img th:src="@{/img/keyimage/notice_img.svg}" alt="공지사항 아이콘" class="service-keyImg" />
<img th:src="@{/img/keyimage/notice_img.png}" alt="공지사항 아이콘" class="service-keyImg" />
</div>
<div class="service-hero__content">
<div class="service-hero__badge">
@@ -84,7 +84,13 @@
<a th:href="@{/portalnotice/detail(id=${notice.id})}" class="notice-title-link">
<span class="notice-number"
th:text="|[${page.totalElements - (page.number * page.size) - status.index}]|">[1]</span>
<span class="notice-type-badge notice-type-badge--fix" th:if="${notice.fixYn == 'Y'}">고정</span>
<span class="notice-pin-icon" th:if="${notice.fixYn == 'Y'}"
style="margin-right: 6px; display: inline-flex; align-items: center; vertical-align: middle; color: #ef4444;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"
xmlns="http://www.w3.org/2000/svg">
<path d="M16 12V4H17V2H7V4H8V12L6 14V16H11V22L12 23L13 22V16H18V14L16 12Z" />
</svg>
</span>
<span class="notice-type-badge notice-type-badge--incident"
th:if="${notice.noticeType == '3'}">장애</span>
<span class="notice-type-badge notice-type-badge--maintenance"
@@ -1,66 +1,73 @@
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
<body>
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>아이디 찾기</h1>
</div>
</section>
<th:block layout:fragment="contentFragment">
<div class="account-recovery-page">
<div class="account-recovery-container">
<div class="account-recovery-card">
<!-- Tab Navigation -->
<div class="account-recovery-tabs">
<a href="#" class="tab-link active">아이디찾기</a>
<a th:href="@{/account_recovery(tab='resetPassword')}" class="tab-link">비밀번호 초기화</a>
</div>
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
<!-- Result Content Area -->
<div class="account-recovery-result">
<div class="result-header">
<i class="fas fa-check-circle result-icon"></i>
<h2 class="result-title">회원님의 아이디았습니다.</h2>
<p class="result-description" th:if="${#lists.size(foundUsers) > 1}">
동일한 정보로 가입된 계정이 <strong th:text="${#lists.size(foundUsers)}">2</strong>개 있습니다.
</p>
<body>
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>아이디 찾</h1>
</div>
</section>
<th:block layout:fragment="contentFragment">
<div class="account-recovery-page">
<div class="account-recovery-container">
<div class="account-recovery-card">
<!-- Tab Navigation -->
<div class="account-recovery-tabs">
<a href="#" class="tab-link active">아이디찾기</a>
<a th:href="@{/account_recovery(tab='resetPassword')}" class="tab-link">비밀번호 초기화</a>
</div>
<!-- Found Users List -->
<div class="found-users-list">
<div class="found-user-item" th:each="user, stat : ${foundUsers}">
<div class="user-info">
<span class="user-email" th:text="${user.loginId}">test@example.com</span>
<span class="user-date">
(<th:block th:text="${#temporals.format(user.createdDate, 'yyyy.MM.dd')}">2024.01.01</th:block> 가입)
</span>
<!-- Result Content Area -->
<div class="account-recovery-result">
<div class="result-header">
<i class="fas fa-check-circle result-icon"></i>
<h2 class="result-title">회원님의 아이디를 찾았습니다.</h2>
<p class="result-description" th:if="${#lists.size(foundUsers) > 1}">
동일한 정보로 가입된 계정이 <strong th:text="${#lists.size(foundUsers)}">2</strong>개 있습니다.
</p>
</div>
<!-- Found Users List -->
<div class="found-users-list">
<div class="found-user-item" th:each="user, stat : ${foundUsers}">
<div class="user-info">
<span class="user-email" th:text="${user.loginId}">test@example.com</span>
<span class="user-date">
(<th:block th:text="${#temporals.format(user.createdDate, 'yyyy.MM.dd')}">2024.01.01</th:block> 가입)
</span>
</div>
</div>
</div>
<!-- Info Box -->
<div class="result-info-box">
<p class="info-text">
<svg class="notice-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
비밀번호가 기억나지 않는 경우에는
<a th:href="@{/account_recovery(tab='resetPassword')}" class="info-link">비밀번호 초기화</a>
이용해 주세요.
</p>
</div>
</div>
<!-- Info Box -->
<div class="result-info-box">
<p class="info-text">
<i class="fas fa-info-circle"></i>
비밀번호가 기억나지 않는 경우에는
<a th:href="@{/account_recovery(tab='resetPassword')}" class="info-link">비밀번호 초기화</a>
이용해 주세요.
</p>
<!-- Action Buttons -->
<div class="form-actions">
<a th:href="@{/account_recovery(tab='findId')}" class="cancel-button">다시 찾기</a>
<a th:href="@{/login}" class="submit-button">로그인</a>
</div>
</div>
<!-- Action Buttons -->
<div class="form-actions">
<a th:href="@{/account_recovery(tab='findId')}" class="cancel-button">다시 찾기</a>
<a th:href="@{/login}" class="submit-button">로그인</a>
</div>
</div>
</div>
</div>
</th:block>
</th:block>
</body>
<th:block layout:fragment="contentScript">
</th:block>
</html>
</html>
@@ -17,7 +17,7 @@
<p class="hero-subtitle">세상의 모든 서비스</p>
<h2 class="hero-title">DJBank API가<br>함께 합니다.</h2>
</div>
<a href="#" class="btn-hero-signup">회원 가입 안내 <i class="bi bi-chevron-right"></i></a>
<a th:href="@{/service/guide}" class="btn-hero-signup">회원 가입 안내 <i class="bi bi-chevron-right"></i></a>
</div>
<div class="hero-image-content">
<!-- Inline SVG Tech Illustration -->
@@ -47,7 +47,8 @@
<p class="hero-subtitle">문서는 상세하게, 연동은 확실하게</p>
<h2 class="hero-title">준비된 DJBank API로 <br>완벽한 서비스를 성공하세요.</h2>
</div>
<a href="#" class="btn-hero-signup">개발 가이드 보기 <i class="bi bi-chevron-right"></i></a>
<a th:href="@{/service/oauth2-guide}" class="btn-hero-signup">개발 가이드 보기 <i
class="bi bi-chevron-right"></i></a>
</div>
<div class="hero-image-content">
<svg class="hero-illustration" viewBox="0 0 500 400" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -72,7 +73,7 @@
<p class="hero-subtitle">상상하는 금융의 구현</p>
<h2 class="hero-title">기업의 비즈니스를<br>혁신합니다.</h2>
</div>
<a href="#" class="btn-hero-signup">제휴 문의하기 <i class="bi bi-chevron-right"></i></a>
<a th:href="@{/partnership}" class="btn-hero-signup">제휴 문의하기 <i class="bi bi-chevron-right"></i></a>
</div>
<div class="hero-image-content">
<svg class="hero-illustration" viewBox="0 0 500 400" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -146,40 +147,6 @@
<!-- 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-text-content">*/-->
<!--/* <h2 class="search-title">*/-->
<!--/* 원하는 API를<br>*/-->
<!-- 지금 검색해 보세요.-->
<!--/* </h2>*/-->
<!--/* </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">*/-->
<!--/* <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"/>*/-->
<!--/* </svg>*/-->
<!--/* </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>*/-->
<!-- 피그마 시안 적용 카드 레이아웃 -->
@@ -328,7 +295,8 @@
</p>
<div class="action-buttons">
<a th:href="@{/service/intro}" class="action-btn btn-secondary">처음 만나는 DJBank API</a>
<a th:href="@{/partnership}" class="action-btn btn-primary">피드백 / 개선요청 <i class="bi bi-patch-question"></i></a>
<a th:href="@{/partnership}" class="action-btn btn-primary">피드백 / 개선요청 <i
class="bi bi-patch-question"></i></a>
</div>
</div>
<div class="info-image-box">
@@ -597,268 +565,284 @@
<!-- 로그인 후 처리 스크립트 -->
<script th:src="@{/js/login-success-handler.js}"></script>
<script th:inline="javascript">
$(document).ready(function () {
LoginSuccessHandler.init({
successMsg: [[${ success }]],
emailVerificationRequired: [[${ session.emailVerificationRequired }]],
dormantAccount: [[${ session.dormantAccount }]],
passwordExpired: [[${ session.passwordExpired }]],
pendingInvitation: [[${ session.pendingInvitation }]],
pendingInvitationToken: [[${ session.pendingInvitationToken }]],
pendingInvitationOrgName: [[${ session.pendingInvitationOrgName }]],
needClientRegister: [[${ needClientRegister }]],
sessionSuccessMsg: [[${ session.success }]],
redirectUrl: [[${ session.redirectUrl }]]
});
});
</script>
<script>
document.addEventListener('DOMContentLoaded', function () {
// ========================================
// Hero Carousel
// ========================================
const heroCarousel = {
currentSlide: 0,
totalSlides: 3,
autoPlayInterval: null,
autoPlayDelay: 5000,
isPlaying: true,
init: function () {
this.slides = document.querySelectorAll('.hero-slide');
this.indicators = document.querySelectorAll('.hero-indicator');
this.prevBtn = document.getElementById('heroPrevBtn');
this.nextBtn = document.getElementById('heroNextBtn');
this.autoplayToggle = document.getElementById('heroAutoplayToggle');
this.pauseIcon = this.autoplayToggle?.querySelector('.pause-icon');
this.playIcon = this.autoplayToggle?.querySelector('.play-icon');
if (!this.slides.length) return;
this.bindEvents();
this.startAutoPlay();
},
bindEvents: function () {
// Previous button
if (this.prevBtn) {
this.prevBtn.addEventListener('click', () => {
this.goToSlide(this.currentSlide - 1);
this.resetAutoPlay();
});
}
// Next button
if (this.nextBtn) {
this.nextBtn.addEventListener('click', () => {
this.goToSlide(this.currentSlide + 1);
this.resetAutoPlay();
});
}
// Indicators
this.indicators.forEach((indicator, index) => {
indicator.addEventListener('click', () => {
this.goToSlide(index);
this.resetAutoPlay();
});
});
// Auto play toggle button
if (this.autoplayToggle) {
this.autoplayToggle.addEventListener('click', () => {
this.toggleAutoPlay();
});
}
// Pause on hover (only if playing)
const container = document.querySelector('.hero-carousel-container');
if (container) {
container.addEventListener('mouseenter', () => {
if (this.isPlaying) {
this.pauseAutoPlay();
}
});
container.addEventListener('mouseleave', () => {
if (this.isPlaying) {
this.startAutoPlay();
}
});
}
// Touch events for mobile
let touchStartX = 0;
let touchEndX = 0;
if (container) {
container.addEventListener('touchstart', (e) => {
touchStartX = e.changedTouches[0].screenX;
});
container.addEventListener('touchend', (e) => {
touchEndX = e.changedTouches[0].screenX;
this.handleSwipe(touchStartX, touchEndX);
});
}
},
goToSlide: function (index) {
// Wrap around
if (index < 0) {
index = this.totalSlides - 1;
} else if (index >= this.totalSlides) {
index = 0;
}
// Update slides
this.slides.forEach((slide, i) => {
slide.classList.toggle('active', i === index);
});
// Update indicators
this.indicators.forEach((indicator, i) => {
indicator.classList.toggle('active', i === index);
});
this.currentSlide = index;
},
handleSwipe: function (startX, endX) {
const minSwipeDistance = 50;
const distance = startX - endX;
if (Math.abs(distance) > minSwipeDistance) {
if (distance > 0) {
// Swipe left - next slide
this.goToSlide(this.currentSlide + 1);
} else {
// Swipe right - previous slide
this.goToSlide(this.currentSlide - 1);
}
this.resetAutoPlay();
}
},
startAutoPlay: function () {
this.pauseAutoPlay();
this.autoPlayInterval = setInterval(() => {
this.goToSlide(this.currentSlide + 1);
}, this.autoPlayDelay);
},
pauseAutoPlay: function () {
if (this.autoPlayInterval) {
clearInterval(this.autoPlayInterval);
this.autoPlayInterval = null;
}
},
resetAutoPlay: function () {
if (this.isPlaying) {
this.pauseAutoPlay();
this.startAutoPlay();
}
},
toggleAutoPlay: function () {
this.isPlaying = !this.isPlaying;
if (this.isPlaying) {
// Start auto play
this.startAutoPlay();
// Update icons
if (this.pauseIcon) this.pauseIcon.classList.add('active');
if (this.playIcon) this.playIcon.classList.remove('active');
// Update aria-label
if (this.autoplayToggle) {
this.autoplayToggle.setAttribute('aria-label', '자동 재생 일시정지');
}
} else {
// Stop auto play
this.pauseAutoPlay();
// Update icons
if (this.pauseIcon) this.pauseIcon.classList.remove('active');
if (this.playIcon) this.playIcon.classList.add('active');
// Update aria-label
if (this.autoplayToggle) {
this.autoplayToggle.setAttribute('aria-label', '자동 재생 시작');
}
}
}
};
// Initialize hero carousel
heroCarousel.init();
// ========================================
// API 검색 기능
// ========================================
const searchInput = document.getElementById('apiSearchInput');
const searchForm = document.querySelector('.search-form');
const hashtags = document.querySelectorAll('.hashtag-link');
// 해시태그 클릭 이벤트
hashtags.forEach(tag => {
tag.addEventListener('click', function (e) {
e.preventDefault();
const searchTerm = this.getAttribute('data-search');
if (searchInput) {
searchInput.value = searchTerm;
}
// 폼 제출
if (searchForm) {
searchForm.submit();
}
<script th:inline="javascript">
$(document).ready(function () {
LoginSuccessHandler.init({
successMsg: [[${ success }]],
emailVerificationRequired: [[${ session.emailVerificationRequired }]],
dormantAccount: [[${ session.dormantAccount }]],
passwordExpired: [[${ session.passwordExpired }]],
pendingInvitation: [[${ session.pendingInvitation }]],
pendingInvitationToken: [[${ session.pendingInvitationToken }]],
pendingInvitationOrgName: [[${ session.pendingInvitationOrgName }]],
needClientRegister: [[${ needClientRegister }]],
sessionSuccessMsg: [[${ session.success }]],
redirectUrl: [[${ session.redirectUrl }]]
});
});
</script>
// ========================================
// 숫자 카운트업 애니메이션
// ========================================
const observerOptions = {
threshold: 0.5,
rootMargin: '0px 0px -100px 0px'
};
<script>
document.addEventListener('DOMContentLoaded', function () {
// ========================================
// Hero Carousel
// ========================================
const heroCarousel = {
currentSlide: 0,
totalSlides: 3,
autoPlayInterval: null,
autoPlayDelay: 5000,
isPlaying: true,
const animateNumbers = (entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const numbers = entry.target.querySelectorAll('.stat-number');
init: function () {
this.slides = document.querySelectorAll('.hero-slide');
this.indicators = document.querySelectorAll('.hero-indicator');
this.prevBtn = document.getElementById('heroPrevBtn');
this.nextBtn = document.getElementById('heroNextBtn');
this.autoplayToggle = document.getElementById('heroAutoplayToggle');
this.pauseIcon = this.autoplayToggle?.querySelector('.pause-icon');
this.playIcon = this.autoplayToggle?.querySelector('.play-icon');
numbers.forEach(number => {
const target = parseInt(number.getAttribute('data-target'));
const duration = 2000; // 2초
const increment = target / (duration / 16);
let current = 0;
if (!this.slides.length) return;
const updateNumber = () => {
current += increment;
if (current < target) {
number.textContent = Math.floor(current).toLocaleString('ko-KR');
requestAnimationFrame(updateNumber);
} else {
number.textContent = target.toLocaleString('ko-KR');
this.bindEvents();
this.startAutoPlay();
},
bindEvents: function () {
// Previous button
if (this.prevBtn) {
this.prevBtn.addEventListener('click', () => {
this.goToSlide(this.currentSlide - 1);
this.resetAutoPlay();
});
}
// Next button
if (this.nextBtn) {
this.nextBtn.addEventListener('click', () => {
this.goToSlide(this.currentSlide + 1);
this.resetAutoPlay();
});
}
// Indicators
this.indicators.forEach((indicator, index) => {
indicator.addEventListener('click', () => {
this.goToSlide(index);
this.resetAutoPlay();
});
});
// Auto play toggle button
if (this.autoplayToggle) {
this.autoplayToggle.addEventListener('click', () => {
this.toggleAutoPlay();
});
}
// Pause on hover (only if playing)
const container = document.querySelector('.hero-carousel-container');
if (container) {
container.addEventListener('mouseenter', () => {
if (this.isPlaying) {
this.pauseAutoPlay();
}
};
});
container.addEventListener('mouseleave', () => {
if (this.isPlaying) {
this.startAutoPlay();
}
});
}
updateNumber();
// Touch events for mobile
let touchStartX = 0;
let touchEndX = 0;
if (container) {
container.addEventListener('touchstart', (e) => {
touchStartX = e.changedTouches[0].screenX;
});
container.addEventListener('touchend', (e) => {
touchEndX = e.changedTouches[0].screenX;
this.handleSwipe(touchStartX, touchEndX);
});
}
},
goToSlide: function (index) {
// Wrap around
if (index < 0) {
index = this.totalSlides - 1;
} else if (index >= this.totalSlides) {
index = 0;
}
// Update slides
this.slides.forEach((slide, i) => {
slide.classList.toggle('active', i === index);
});
observer.unobserve(entry.target);
// Update indicators
this.indicators.forEach((indicator, i) => {
indicator.classList.toggle('active', i === index);
});
// Retrigger draw-line and pulsing animations for Slide 3 (index 2)
if (index === 2) {
const drawLinePath = this.slides[2].querySelector('.draw-line');
const pulsingCircle = this.slides[2].querySelector('.pulsing');
if (drawLinePath) {
drawLinePath.style.animation = 'none';
drawLinePath.offsetHeight;
drawLinePath.style.animation = null;
}
if (pulsingCircle) {
pulsingCircle.style.animation = 'none';
pulsingCircle.offsetHeight;
pulsingCircle.style.animation = null;
}
}
this.currentSlide = index;
},
handleSwipe: function (startX, endX) {
const minSwipeDistance = 50;
const distance = startX - endX;
if (Math.abs(distance) > minSwipeDistance) {
if (distance > 0) {
// Swipe left - next slide
this.goToSlide(this.currentSlide + 1);
} else {
// Swipe right - previous slide
this.goToSlide(this.currentSlide - 1);
}
this.resetAutoPlay();
}
},
startAutoPlay: function () {
this.pauseAutoPlay();
this.autoPlayInterval = setInterval(() => {
this.goToSlide(this.currentSlide + 1);
}, this.autoPlayDelay);
},
pauseAutoPlay: function () {
if (this.autoPlayInterval) {
clearInterval(this.autoPlayInterval);
this.autoPlayInterval = null;
}
},
resetAutoPlay: function () {
if (this.isPlaying) {
this.pauseAutoPlay();
this.startAutoPlay();
}
},
toggleAutoPlay: function () {
this.isPlaying = !this.isPlaying;
if (this.isPlaying) {
// Start auto play
this.startAutoPlay();
// Update icons
if (this.pauseIcon) this.pauseIcon.classList.add('active');
if (this.playIcon) this.playIcon.classList.remove('active');
// Update aria-label
if (this.autoplayToggle) {
this.autoplayToggle.setAttribute('aria-label', '자동 재생 일시정지');
}
} else {
// Stop auto play
this.pauseAutoPlay();
// Update icons
if (this.pauseIcon) this.pauseIcon.classList.remove('active');
if (this.playIcon) this.playIcon.classList.add('active');
// Update aria-label
if (this.autoplayToggle) {
this.autoplayToggle.setAttribute('aria-label', '자동 재생 시작');
}
}
}
};
// Initialize hero carousel
heroCarousel.init();
// ========================================
// API 검색 기능
// ========================================
const searchInput = document.getElementById('apiSearchInput');
const searchForm = document.querySelector('.search-form');
const hashtags = document.querySelectorAll('.hashtag-link');
// 해시태그 클릭 이벤트
hashtags.forEach(tag => {
tag.addEventListener('click', function (e) {
e.preventDefault();
const searchTerm = this.getAttribute('data-search');
if (searchInput) {
searchInput.value = searchTerm;
}
// 폼 제출
if (searchForm) {
searchForm.submit();
}
});
});
};
const observer = new IntersectionObserver(animateNumbers, observerOptions);
const statsSection = document.querySelector('.api-stats-section');
// ========================================
// 숫자 카운트업 애니메이션
// ========================================
const observerOptions = {
threshold: 0.5,
rootMargin: '0px 0px -100px 0px'
};
if (statsSection) {
observer.observe(statsSection);
}
});
</script>
const animateNumbers = (entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const numbers = entry.target.querySelectorAll('.stat-number');
numbers.forEach(number => {
const target = parseInt(number.getAttribute('data-target'));
const duration = 2000; // 2초
const increment = target / (duration / 16);
let current = 0;
const updateNumber = () => {
current += increment;
if (current < target) {
number.textContent = Math.floor(current).toLocaleString('ko-KR');
requestAnimationFrame(updateNumber);
} else {
number.textContent = target.toLocaleString('ko-KR');
}
};
updateNumber();
});
observer.unobserve(entry.target);
}
});
};
const observer = new IntersectionObserver(animateNumbers, observerOptions);
const statsSection = document.querySelector('.api-stats-section');
if (statsSection) {
observer.observe(statsSection);
}
});
</script>
</th:block>
</html>
@@ -14,7 +14,7 @@
</div>
</section>
<section layout:fragment="contentFragment">
<div class="service-main app-management-layout">
<div class="service-main container" style="padding-top: 70px;">
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('password')}"></th:block>
<div class="app-management-content">
<div class="password-change-wrapper">
@@ -24,11 +24,12 @@
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<div class="register-form-container">
<div class="info-notice-box">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4685ef"
stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<path d="M12 16v-4"></path>
<path d="M12 8h.01"></path>
<svg class="notice-icon" width="20" height="20" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<p>소중한 계정 보호를 위해 비밀번호를 변경해 주세요!<br>
비밀번호 변경이 완료되면 자동으로 로그아웃되며, 새 비밀번호로 다시 로그인해야 합니다.</p>
@@ -1,80 +1,84 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/djbank_title_layout}">
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/djbank_title_layout}">
<body>
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>비밀번호 변경</h1>
</div>
</section>
<section layout:fragment="contentFragment">
<div class="service-main app-management-layout">
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('password')}"></th:block>
<div class="app-management-content">
<div class="password-change-wrapper">
<h2 class="page-outer-title">비밀번호 변경</h2>
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>비밀번호 변경</h1>
</div>
</section>
<section layout:fragment="contentFragment">
<div class="service-main app-management-layout">
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('password')}"></th:block>
<div class="app-management-content">
<div class="password-change-wrapper">
<h2 class="page-outer-title">비밀번호 변경</h2>
<form th:action="@{/password/verify}" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<form th:action="@{/password/verify}" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<div class="register-form-container">
<div class="info-notice-box">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4685ef" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<path d="M12 16v-4"></path>
<path d="M12 8h.01"></path>
</svg>
<p>비밀번호 변경을 위하여 현재 비밀번호를 입력해 주세요</p>
</div>
<div class="form-row">
<div class="form-label-wrapper">
<span class="form-label-text">현재 비밀번호</span>
<div class="register-form-container">
<div class="info-notice-box">
<svg class="notice-icon" width="20" height="20" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<p>비밀번호 변경을 위하여 현재 비밀번호를 입력해 주세요</p>
</div>
<div class="form-field-wrapper">
<input type="password" name="currentPassword" class="form-input"
placeholder="비밀번호 입력" required>
<div class="form-row">
<div class="form-label-wrapper">
<span class="form-label-text">현재 비밀번호</span>
</div>
<div class="form-field-wrapper">
<input type="password" name="currentPassword" class="form-input"
placeholder="비밀번호 입력" required>
</div>
</div>
</div>
</div>
<div class="form-actions" style="justify-content: flex-end;">
<div class="right-buttons">
<button type="button" class="btn-cancel" th:unless="${forcedPasswordReset}" th:onclick="|location.href='@{/}'|">취소</button>
<button type="submit" class="btn-apply btn-primary">확인</button>
<div class="form-actions" style="justify-content: flex-end;">
<div class="right-buttons">
<button type="button" class="btn-cancel" th:unless="${forcedPasswordReset}"
th:onclick="|location.href='@{/}'|">취소</button>
<button type="submit" class="btn-apply btn-primary">확인</button>
</div>
</div>
</div>
</form>
</form>
</div>
</div>
</div>
</div>
<script th:if="${error}" th:inline="javascript">
$(document).ready(function() {
customPopups.showAlert([[${error}]]);
})
</script>
<script th:if="${forcedPasswordReset}" th:inline="javascript">
// 비밀번호 재설정 강제(ENFORCE): "변경" 또는 "로그아웃"만 선택 가능
$(function () {
$('#customConfirmCloseButton').hide();
$('#customConfirmYesButton').text('변경');
$('#customConfirmNoButton').text('로그아웃');
customPopups.showConfirm(
'계정 보안을 위해 비밀번호 재설정이 필요합니다.<br>비밀번호를 변경하거나 로그아웃해 주세요.',
function (selection) {
if (selection) {
// 변경: 팝업 닫고 현재 페이지(비밀번호 변경)에서 진행
$('#customConfirmCloseButton').show();
} else {
// 로그아웃
window.location.href = [[@{/actionLogout.do}]];
<script th:if="${error}" th:inline="javascript">
$(document).ready(function () {
customPopups.showAlert([[${ error }]]);
})
</script>
<script th:if="${forcedPasswordReset}" th:inline="javascript">
// 비밀번호 재설정 강제(ENFORCE): "변경" 또는 "로그아웃"만 선택 가능
$(function () {
$('#customConfirmCloseButton').hide();
$('#customConfirmYesButton').text('변경');
$('#customConfirmNoButton').text('로그아웃');
customPopups.showConfirm(
'계정 보안을 위해 비밀번호 재설정이 필요합니다.<br>비밀번호를 변경하거나 로그아웃해 주세요.',
function (selection) {
if (selection) {
// 변경: 팝업 닫고 현재 페이지(비밀번호 변경)에서 진행
$('#customConfirmCloseButton').show();
} else {
// 로그아웃
window.location.href = [[@{/ actionLogout.do}]];
}
}
);
});
</script>
</section>
</script>
</section>
</body>
</html>
</html>
@@ -9,34 +9,49 @@
</div>
</section>
<section layout:fragment="contentFragment">
<div class="org-register-container">
<div class="corp-manager-wrapper">
<div class="common-title-bar">
<h2 class="common-title">기본 정보</h2>
</div>
<div class="register-form-container">
<div class="service-main container" style="padding-top: 70px; padding-bottom:100px;">
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('profile')}"></th:block>
<div class="app-management-content">
<div class="corp-manager-wrapper">
<h2 class="page-outer-title">정보 관리</h2>
<form id="updateForm" method="post" th:action="@{/mypage/update}">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<div class="register-form">
<div class="form-row">
<div class="form-label-wrapper">
<span class="form-label-text">소속기관</span>
</div>
<div class="form-field-wrapper">
<input type="text" th:value="${user.portalOrg.orgName}" name="organization"
class="form-input input-readonly info-value-box" placeholder="소속기관" disabled="disabled">
</div>
<div class="register-form-container">
<!-- Section Title -->
<div class="inner-section-title">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
<h3>기본 정보</h3>
</div>
<div class="register-form">
<div class="form-row">
<div class="form-label-wrapper">
<span class="form-label-text">소속기관</span>
</div>
<div class="form-field-wrapper">
<input type="text" th:value="${user.portalOrg.orgName}" name="organization"
class="form-input input-readonly" placeholder="소속기관" disabled="disabled">
</div>
</div>
<th:block th:replace="~{apps/mypage/components/commonUserInfo :: commonUserInfo}"></th:block>
</div>
<th:block th:replace="~{apps/mypage/components/commonUserInfo :: commonUserInfo}"></th:block>
</div>
</form>
</div>
<!-- Action Buttons -->
<div class="form-actions form-actions--with-withdrawal">
<a class="withdrawal-link"><img th:src="@{/img/btn_withdrawal.png}" alt="회원탈퇴">회원탈퇴</a>
<div class="form-actions-buttons">
<button type="button" class="btn btn-submit btn-secondary" th:onclick="|location.href='@{/}'|">취소</button>
<button type="button" class="btn btn-submit btn-primary submit-btn">수정 적용</button>
<!-- Action Buttons -->
<div class="form-actions">
<a class="withdrawal-link btn-withdrawal">회원탈퇴</a>
<div class="right-buttons">
<button type="button" class="btn-cancel" th:onclick="|location.href='@{/}'|">취소</button>
<button type="button" class="btn-apply btn-primary submit-btn">수정</button>
</div>
</div>
</div>
</div>
@@ -1,524 +1,529 @@
<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/djbank_title_layout}">
<html lang="ko" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/djbank_title_layout}">
<body>
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" alt="법인회원가입" class="title-image">
<h1>법인회원가입</h1>
</div>
</section>
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" alt="법인회원가입" class="title-image">
<h1>법인회원가입</h1>
</div>
</section>
<section layout:fragment="contentFragment">
<section layout:fragment="contentFragment">
<div class="org-register-page corporate-register">
<!-- Page Title Banner -->
<div class="org-register-page corporate-register">
<!-- Page Title Banner -->
<div class="org-register-container">
<!-- Hidden Fields -->
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<input type="hidden" name="registrationType" th:value="${registrationType}"/>
<div class="org-register-container">
<!-- Hidden Fields -->
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<input type="hidden" name="registrationType" th:value="${registrationType}" />
<!-- Alert Container -->
<div id="alertContainer"></div>
<!-- Registration Card Wrapper -->
<div class="register-card-wrapper">
<!-- Info Notice -->
<div class="org-info-notice">
<div class="notice-icon-wrapper">
<svg class="notice-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</div>
<p class="notice-text">법인 회원 가입 후 서비스 또는 API 이용을 하실 수 있습니다.</p>
</div>
<!-- Agreement Section -->
<th:block th:replace="~{apps/register/userAgreementContent :: agreementContent(${termsOfUse}, ${privacyCollect})}"></th:block>
<form name="portalOrg" id="registerForm" role="form" th:action="@{/signup/portalOrg}"
th:object="${portalOrg}" method="post">
<!-- Corporate Basic Information Section -->
<div class="org-section-header org-section-header--agreement">
<h3>법인 기본 정보</h3>
<span class="required-badge">필수 입력</span>
</div>
<th:block th:replace="~{apps/register/components/orgBasicInfoForm :: orgInfoForm}"></th:block>
<!-- Corporate Admin Information Section -->
<div class="org-section-header org-section-header--agreement" style="margin-top: 48px;">
<h3>법인 관리자 정보</h3>
<span class="required-badge">필수 입력</span>
</div>
<!-- Alert Container -->
<div id="alertContainer"></div>
<!-- Registration Card Wrapper -->
<div class="register-card-wrapper">
<!-- Info Notice -->
<div class="org-info-notice">
<div class="notice-icon-wrapper">
<svg class="notice-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<svg class="notice-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</div>
<p class="notice-text">법인관리자는 API 신청 및 개발자 초대 등 모든 권한을 보유합니다.</p>
<p class="notice-text">법인 회원 가입 후 서비스 또는 API 이용을 하실 수 있습니다.</p>
</div>
<th:block th:if="${registrationType == 'corporate'}">
<th:block th:replace="~{apps/register/components/orgUserInfoForm :: orgUserInfoForm}"></th:block>
<!-- Agreement Section -->
<th:block
th:replace="~{apps/register/userAgreementContent :: agreementContent(${termsOfUse}, ${privacyCollect})}">
</th:block>
<!-- Action Buttons -->
<div class="org-action-buttons">
<button type="button" class="btn btn-secondary" id="cancelButton">취소</button>
<button type="button" class="btn btn-primary btn_register">가입신청</button>
</div>
</form>
</div>
<form name="portalOrg" id="registerForm" role="form" th:action="@{/signup/portalOrg}" th:object="${portalOrg}"
method="post">
<!-- Loading Overlay -->
<div class="org-loading-overlay" id="loadingOverlay">
<div class="spinner"></div>
<!-- Corporate Basic Information Section -->
<div class="org-section-header org-section-header--agreement">
<h3>법인 기본 정보</h3>
<span class="required-badge">필수 입력</span>
</div>
<th:block th:replace="~{apps/register/components/orgBasicInfoForm :: orgInfoForm}"></th:block>
<!-- Corporate Admin Information Section -->
<div class="org-section-header org-section-header--agreement" style="margin-top: 48px;">
<h3>법인 관리자 정보</h3>
<span class="required-badge">필수 입력</span>
</div>
<div class="org-info-notice">
<div class="notice-icon-wrapper">
<svg class="notice-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</div>
<p class="notice-text">법인관리자는 API 신청 및 개발자 초대 등 모든 권한을 보유합니다.</p>
</div>
<th:block th:if="${registrationType == 'corporate'}">
<th:block th:replace="~{apps/register/components/orgUserInfoForm :: orgUserInfoForm}"></th:block>
</th:block>
<!-- Action Buttons -->
<div class="org-action-buttons">
<button type="button" class="btn btn-secondary" id="cancelButton">취소</button>
<button type="button" class="btn btn-primary btn_register">가입신청</button>
</div>
</form>
</div>
<!-- Loading Overlay -->
<div class="org-loading-overlay" id="loadingOverlay">
<div class="spinner"></div>
</div>
</div>
</div>
</div>
</section>
</section>
<th:block layout:fragment="contentScript">
<script th:if="${error}" th:inline="javascript">
$(document).ready(function () {
customPopups.showAlert([[${error}]]);
});
</script>
<script th:replace="~{apps/register/userAgreementContent :: agreementScript}"></script>
<script th:replace="~{apps/register/components/orgBasicInfoForm :: orgInfoScript}"></script>
<script th:replace="~{apps/register/components/orgUserInfoForm :: orgUserInfoScript}"></script>
<script th:replace="~{apps/register/components/emailChangeForm :: emailChangeScript}"></script>
<script th:replace="~{apps/register/components/newUserInfoForm :: newUserScript}"></script>
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function () {
let form = document.getElementById('registerForm');
let agreementForm = document.getElementById('agreementForm');
let btn_register = document.querySelector('.btn_register');
let isAuthVerified = false;
<th:block layout:fragment="contentScript">
<script th:if="${error}" th:inline="javascript">
$(document).ready(function () {
customPopups.showAlert([[${ error }]]);
});
</script>
<script th:replace="~{apps/register/userAgreementContent :: agreementScript}"></script>
<script th:replace="~{apps/register/components/orgBasicInfoForm :: orgInfoScript}"></script>
<script th:replace="~{apps/register/components/orgUserInfoForm :: orgUserInfoScript}"></script>
<script th:replace="~{apps/register/components/emailChangeForm :: emailChangeScript}"></script>
<script th:replace="~{apps/register/components/newUserInfoForm :: newUserScript}"></script>
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function () {
let form = document.getElementById('registerForm');
let agreementForm = document.getElementById('agreementForm');
let btn_register = document.querySelector('.btn_register');
let isAuthVerified = false;
// registrationScenario를 hidden input에서 가져오도록 수정
let registrationScenario = document.getElementById('registrationScenario')?.value || 'new';
// registrationScenario를 hidden input에서 가져오도록 수정
let registrationScenario = document.getElementById('registrationScenario')?.value || 'new';
// hidden input의 값이 변경되면 registrationScenario 변수도 업데이트
const scenarioInput = document.getElementById('registrationScenario');
if (scenarioInput) {
scenarioInput.addEventListener('change', function () {
registrationScenario = this.value;
console.log('Registration Scenario Updated:', registrationScenario);
});
// MutationObserver를 사용하여 값 변경 감지
const observer = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
if (mutation.type === 'attributes' && mutation.attributeName === 'value') {
registrationScenario = scenarioInput.value;
console.log('Registration Scenario Updated (from mutation):', registrationScenario);
}
});
});
observer.observe(scenarioInput, {attributes: true});
}
// 시나리오별 필수 필드 정의
const requiredFieldsByScenario = {
new: {
user: ['loginId', 'userName', 'password', 'password2', 'mobileNumber', 'authNumber'],
org: ['compRegNo', 'corpRegNo', 'orgName', 'compRegFile', 'files']
},
retain: {
user: ['loginId', 'passwordConfirmIndividual'],
org: ['compRegNo', 'corpRegNo', 'orgName', 'compRegFile', 'files']
},
change: {
user: ['loginId', 'passwordConfirmEmailChange', 'newLoginId'],
org: ['compRegNo', 'corpRegNo', 'orgName', 'compRegFile', 'files']
}
};
// 제출 대상 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@]+$/;
return emailRegex.test(email);
}
function collectFormData() {
const form = document.createElement('form');
form.enctype = 'multipart/form-data';
form.method = 'POST';
form.action = '/signup/portalOrg';
form.style.display = 'none';
const fields = requiredFieldsByScenario[registrationScenario];
if (!fields) {
console.error('Unknown scenario');
return form;
}
// 시나리오 값 추가
// hidden input의 값이 변경되면 registrationScenario 변수도 업데이트
const scenarioInput = document.getElementById('registrationScenario');
if (scenarioInput) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'registrationScenario';
input.value = scenarioInput.value;
form.appendChild(input);
} else {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'registrationScenario';
input.value = registrationScenario;
form.appendChild(input);
scenarioInput.addEventListener('change', function () {
registrationScenario = this.value;
console.log('Registration Scenario Updated:', registrationScenario);
});
// MutationObserver를 사용하여 값 변경 감지
const observer = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
if (mutation.type === 'attributes' && mutation.attributeName === 'value') {
registrationScenario = scenarioInput.value;
console.log('Registration Scenario Updated (from mutation):', registrationScenario);
}
});
});
observer.observe(scenarioInput, { attributes: true });
}
// user와 org 필드를 따로 처리
fields.user.forEach(fieldId => {
const element = document.getElementById(fieldId);
if (element) {
// 시나리오별 필수 필드 정의
const requiredFieldsByScenario = {
new: {
user: ['loginId', 'userName', 'password', 'password2', 'mobileNumber', 'authNumber'],
org: ['compRegNo', 'corpRegNo', 'orgName', 'compRegFile', 'files']
},
retain: {
user: ['loginId', 'passwordConfirmIndividual'],
org: ['compRegNo', 'corpRegNo', 'orgName', 'compRegFile', 'files']
},
change: {
user: ['loginId', 'passwordConfirmEmailChange', 'newLoginId'],
org: ['compRegNo', 'corpRegNo', 'orgName', 'compRegFile', 'files']
}
};
// 제출 대상 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@]+$/;
return emailRegex.test(email);
}
function collectFormData() {
const form = document.createElement('form');
form.enctype = 'multipart/form-data';
form.method = 'POST';
form.action = '/signup/portalOrg';
form.style.display = 'none';
const fields = requiredFieldsByScenario[registrationScenario];
if (!fields) {
console.error('Unknown scenario');
return form;
}
// 시나리오 값 추가
const scenarioInput = document.getElementById('registrationScenario');
if (scenarioInput) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = fieldId;
input.value = element.value;
input.name = 'registrationScenario';
input.value = scenarioInput.value;
form.appendChild(input);
} else {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'registrationScenario';
input.value = registrationScenario;
form.appendChild(input);
}
});
submitOrgFields.forEach(fieldId => {
const element = document.getElementById(fieldId);
if (element) {
if (element.type === 'file') {
const files = element.files;
if (files.length > 0) {
const input = document.createElement('input');
input.type = 'file';
input.name = fieldId;
input.files = files;
form.appendChild(input);
}
} else {
// user와 org 필드를 따로 처리
fields.user.forEach(fieldId => {
const element = document.getElementById(fieldId);
if (element) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = fieldId;
input.value = element.value;
form.appendChild(input);
}
}
});
});
// Add confirmPassword manually based on the scenario
if (registrationScenario === 'retain') {
const passwordConfirmIndividual = document.getElementById('passwordConfirmIndividual');
if (passwordConfirmIndividual) {
submitOrgFields.forEach(fieldId => {
const element = document.getElementById(fieldId);
if (element) {
if (element.type === 'file') {
const files = element.files;
if (files.length > 0) {
const input = document.createElement('input');
input.type = 'file';
input.name = fieldId;
input.files = files;
form.appendChild(input);
}
} else {
const input = document.createElement('input');
input.type = 'hidden';
input.name = fieldId;
input.value = element.value;
form.appendChild(input);
}
}
});
// Add confirmPassword manually based on the scenario
if (registrationScenario === 'retain') {
const passwordConfirmIndividual = document.getElementById('passwordConfirmIndividual');
if (passwordConfirmIndividual) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'confirmPassword';
input.value = passwordConfirmIndividual.value;
form.appendChild(input);
}
} else if (registrationScenario === 'change') {
const passwordConfirmEmailChange = document.getElementById('passwordConfirmEmailChange');
if (passwordConfirmEmailChange) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'confirmPassword';
input.value = passwordConfirmEmailChange.value;
form.appendChild(input);
}
}
// 공통 필드 추가
if (document.getElementById('termsOfUse')) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'confirmPassword';
input.value = passwordConfirmIndividual.value;
input.name = 'termsOfUse';
input.value = document.getElementById('termsOfUse').checked;
form.appendChild(input);
}
} else if (registrationScenario === 'change') {
const passwordConfirmEmailChange = document.getElementById('passwordConfirmEmailChange');
if (passwordConfirmEmailChange) {
if (document.getElementById('privacyCollect')) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'confirmPassword';
input.value = passwordConfirmEmailChange.value;
input.name = 'privacyCollect';
input.value = document.getElementById('privacyCollect').checked;
form.appendChild(input);
}
if (document.getElementById('notificationConsent')) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'notificationConsent';
input.value = document.getElementById('notificationConsent').checked;
form.appendChild(input);
}
// 시스템 필드 추가
const csrfToken = document.querySelector('input[name="_csrf"]');
if (csrfToken) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = '_csrf';
input.value = csrfToken.value;
form.appendChild(input);
}
const registrationType = document.querySelector('input[name="registrationType"]');
if (registrationType) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'registrationType';
input.value = registrationType.value;
form.appendChild(input);
}
console.log('Collected Form Data for scenario:',
document.getElementById('registrationScenario')?.value || registrationScenario);
return form;
}
// 시나리오별 유효성 검사
function validateFormByScenario() {
const currentScenario = document.getElementById('registrationScenario')?.value || 'new';
console.log('Registration Scenario:', currentScenario);
if (!currentScenario) {
console.log('Unknown scenario');
return false;
}
switch (registrationScenario) {
case 'new':
const loginIdElement = document.getElementById('loginId');
const userNameElement = document.getElementById('userName');
const isEmailValid = loginIdElement ? isValidEmail(loginIdElement.value) : false;
const hasUserName = userNameElement ? userNameElement.value.trim() !== '' : false;
const isPasswordValid = document.getElementById('isPasswordValid').value.trim() === 'true';
const isPasswordMatch = document.getElementById('isPasswordMatch').value.trim() === 'true';
console.log('New User Validation:', {
email: loginIdElement?.value,
isEmailValid,
isPasswordValid,
isPasswordMatch,
userName: userNameElement?.value,
hasUserName,
isAuthVerified,
requiredFields: currentScenario
});
return isEmailValid && isPasswordValid && isPasswordMatch && hasUserName && isAuthVerified;
case 'retain':
const passwordConfirmElement = document.getElementById('passwordConfirmIndividual');
const hasPassword = passwordConfirmElement ? passwordConfirmElement.value.length >= 8 : false;
const retainLoginIdElement = document.getElementById('loginId');
console.log('Retain User Validation:', {
email: retainLoginIdElement?.value,
isEmailValid: retainLoginIdElement ? isValidEmail(retainLoginIdElement.value) : false,
passwordLength: passwordConfirmElement?.value.length,
hasPassword,
requiredFields: currentScenario
});
return retainLoginIdElement && isValidEmail(retainLoginIdElement.value) && hasPassword;
case 'change':
const changeLoginIdElement = document.getElementById('loginId');
const newLoginIdElement = document.getElementById('newLoginId');
const changePasswordElement = document.getElementById('passwordConfirmEmailChange');
const hasChangePassword = changePasswordElement ? changePasswordElement.value.length >= 8 : false;
console.log('Change Email Validation:', {
currentEmail: changeLoginIdElement?.value,
newEmail: newLoginIdElement?.value,
hasPassword: hasChangePassword,
requiredFields: currentScenario
});
return changeLoginIdElement && newLoginIdElement &&
isValidEmail(changeLoginIdElement.value) &&
hasChangePassword &&
isValidEmail(newLoginIdElement.value);
default:
console.log('Unknown scenario');
return false;
}
}
// 공통 필드 추가
if (document.getElementById('termsOfUse')) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'termsOfUse';
input.value = document.getElementById('termsOfUse').checked;
form.appendChild(input);
// 공통 필드 유효성 검사
function validateCommonFields() {
if (!validatePhoneNumber('org') || !validatePhoneNumber('sc')) {
return false;
}
const notificationConsentEl = document.getElementById('notificationConsent');
const validations = {
termsOfUse: $('#termsOfUse').prop('checked'),
privacyPolicy: $('#privacyCollect').prop('checked'),
notificationConsent: !notificationConsentEl || notificationConsentEl.checked,
compRegNo: $('#compRegNo').val().trim() !== '',
corpRegNo: $('#corpRegNo').val().trim() !== '',
orgName: $('#orgName').val().trim() !== '',
compRegFile: $('#compRegFile').val().trim() !== ''
};
console.log('Common Fields Validation:', validations);
return Object.values(validations).every(value => value === true);
}
if (document.getElementById('privacyCollect')) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'privacyCollect';
input.value = document.getElementById('privacyCollect').checked;
form.appendChild(input);
// 전체 폼 유효성 검사
function validateForm() {
const scenarioValidation = validateFormByScenario();
const commonValidation = validateCommonFields();
console.log('Form validation result:', {
scenarioValidation,
commonValidation,
registrationScenario
});
return scenarioValidation && commonValidation;
}
if (document.getElementById('notificationConsent')) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'notificationConsent';
input.value = document.getElementById('notificationConsent').checked;
form.appendChild(input);
}
// 미입력/미충족 항목을 구체적으로 수집
function collectFormErrors() {
const errors = [];
// 시스템 필드 추가
const csrfToken = document.querySelector('input[name="_csrf"]');
if (csrfToken) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = '_csrf';
input.value = csrfToken.value;
form.appendChild(input);
}
const registrationType = document.querySelector('input[name="registrationType"]');
if (registrationType) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'registrationType';
input.value = registrationType.value;
form.appendChild(input);
}
console.log('Collected Form Data for scenario:',
document.getElementById('registrationScenario')?.value || registrationScenario);
return form;
}
// 시나리오별 유효성 검사
function validateFormByScenario() {
const currentScenario = document.getElementById('registrationScenario')?.value || 'new';
console.log('Registration Scenario:', currentScenario);
if (!currentScenario) {
console.log('Unknown scenario');
return false;
}
switch (registrationScenario) {
case 'new':
// 시나리오별 항목
if (registrationScenario === 'new') {
const loginIdElement = document.getElementById('loginId');
const userNameElement = document.getElementById('userName');
const isEmailValid = loginIdElement ? isValidEmail(loginIdElement.value) : false;
const hasUserName = userNameElement ? userNameElement.value.trim() !== '' : false;
const isPasswordValid = document.getElementById('isPasswordValid').value.trim() === 'true';
const isPasswordMatch = document.getElementById('isPasswordMatch').value.trim() === 'true';
console.log('New User Validation:', {
email: loginIdElement?.value,
isEmailValid,
isPasswordValid,
isPasswordMatch,
userName: userNameElement?.value,
hasUserName,
isAuthVerified,
requiredFields: currentScenario
});
return isEmailValid && isPasswordValid && isPasswordMatch && hasUserName && isAuthVerified;
case 'retain':
const passwordConfirmElement = document.getElementById('passwordConfirmIndividual');
const hasPassword = passwordConfirmElement ? passwordConfirmElement.value.length >= 8 : false;
const retainLoginIdElement = document.getElementById('loginId');
console.log('Retain User Validation:', {
email: retainLoginIdElement?.value,
isEmailValid: retainLoginIdElement ? isValidEmail(retainLoginIdElement.value) : false,
passwordLength: passwordConfirmElement?.value.length,
hasPassword,
requiredFields: currentScenario
});
return retainLoginIdElement && isValidEmail(retainLoginIdElement.value) && hasPassword;
case 'change':
const changeLoginIdElement = document.getElementById('loginId');
const newLoginIdElement = document.getElementById('newLoginId');
const changePasswordElement = document.getElementById('passwordConfirmEmailChange');
const hasChangePassword = changePasswordElement ? changePasswordElement.value.length >= 8 : false;
console.log('Change Email Validation:', {
currentEmail: changeLoginIdElement?.value,
newEmail: newLoginIdElement?.value,
hasPassword: hasChangePassword,
requiredFields: currentScenario
});
return changeLoginIdElement && newLoginIdElement &&
isValidEmail(changeLoginIdElement.value) &&
hasChangePassword &&
isValidEmail(newLoginIdElement.value);
default:
console.log('Unknown scenario');
return false;
}
}
// 공통 필드 유효성 검사
function validateCommonFields() {
if(!validatePhoneNumber('org') || !validatePhoneNumber('sc')) {
return false;
}
const notificationConsentEl = document.getElementById('notificationConsent');
const validations = {
termsOfUse: $('#termsOfUse').prop('checked'),
privacyPolicy: $('#privacyCollect').prop('checked'),
notificationConsent: !notificationConsentEl || notificationConsentEl.checked,
compRegNo: $('#compRegNo').val().trim() !== '',
corpRegNo: $('#corpRegNo').val().trim() !== '',
orgName: $('#orgName').val().trim() !== '',
compRegFile: $('#compRegFile').val().trim() !== ''
};
console.log('Common Fields Validation:', validations);
return Object.values(validations).every(value => value === true);
}
// 전체 폼 유효성 검사
function validateForm() {
const scenarioValidation = validateFormByScenario();
const commonValidation = validateCommonFields();
console.log('Form validation result:', {
scenarioValidation,
commonValidation,
registrationScenario
});
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;
// 등록 버튼 클릭 이벤트
btn_register.addEventListener('click', function (event) {
event.preventDefault();
event.stopPropagation();
// Prevent resubmission
if (isSubmitting) {
console.log('Form is already being submitted...');
return;
}
isSubmitting = true;
btn_register.disabled = true;
// Show loading overlay
document.getElementById('loadingOverlay').classList.add('active');
try {
form.classList.add('was-validated');
if (agreementForm) {
agreementForm.classList.add('was-validated');
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('비밀번호 확인이 일치하지 않습니다.');
}
console.log('Validating form...');
// 공통 항목
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('알림 수신에 동의해주세요.');
if (validateForm()) {
console.log('Form validation passed, collecting form data...');
const form = collectFormData();
document.body.appendChild(form);
form.submit();
} else {
console.log('Form validation failed');
return errors;
}
// 현재 상태 출력
const scenarioValidation = validateFormByScenario();
const commonValidation = validateCommonFields();
let isSubmitting = false;
const isPasswordValid = document.getElementById('isPasswordValid')?.value.trim() === 'true';
const isPasswordMatch = document.getElementById('isPasswordMatch')?.value.trim() === 'true';
// 등록 버튼 클릭 이벤트
btn_register.addEventListener('click', function (event) {
event.preventDefault();
event.stopPropagation();
console.log('Validation details:', {
scenarioValidation,
commonValidation,
registrationScenario,
isAuthVerified,
isPasswordValid,
isPasswordMatch
});
// Prevent resubmission
if (isSubmitting) {
console.log('Form is already being submitted...');
return;
}
const errors = collectFormErrors();
let errorMessage = errors.length > 0
? '아래 항목을 확인해주세요.<br><br>' + errors.map(e => '• ' + e).join('<br>')
: '모든 필수 항목을 입력하고 약관에 동의해주세요.';
customPopups.showAlert(errorMessage);
isSubmitting = true;
btn_register.disabled = true;
// Reset submission state since validation failed
// Show loading overlay
document.getElementById('loadingOverlay').classList.add('active');
try {
form.classList.add('was-validated');
if (agreementForm) {
agreementForm.classList.add('was-validated');
}
console.log('Validating form...');
if (validateForm()) {
console.log('Form validation passed, collecting form data...');
const form = collectFormData();
document.body.appendChild(form);
form.submit();
} else {
console.log('Form validation failed');
// 현재 상태 출력
const scenarioValidation = validateFormByScenario();
const commonValidation = validateCommonFields();
const isPasswordValid = document.getElementById('isPasswordValid')?.value.trim() === 'true';
const isPasswordMatch = document.getElementById('isPasswordMatch')?.value.trim() === 'true';
console.log('Validation details:', {
scenarioValidation,
commonValidation,
registrationScenario,
isAuthVerified,
isPasswordValid,
isPasswordMatch
});
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
isSubmitting = false;
btn_register.disabled = false;
document.getElementById('loadingOverlay').classList.remove('active');
}
} catch (error) {
console.error('Form submission error:', error);
// Reset submission state on error
isSubmitting = false;
btn_register.disabled = false;
document.getElementById('loadingOverlay').classList.remove('active');
}
} catch (error) {
console.error('Form submission error:', error);
// Reset submission state on error
isSubmitting = false;
btn_register.disabled = false;
document.getElementById('loadingOverlay').classList.remove('active');
});
// 신규 이메일 중복 체크 응답 처리 함수
function handleNewEmailValidationResponse(response) {
if (response.valid) {
customPopups.showAlert(response.message || '신규 이메일 정보입니다.');
} else {
customPopups.showAlert(response.message || '이미 사용 중인 이메일입니다.');
}
}
});
// 신규 이메일 중복 체크 응답 처리 함수
function handleNewEmailValidationResponse(response) {
if (response.valid) {
customPopups.showAlert(response.message || '신규 이메일 정보입니다.');
} else {
customPopups.showAlert(response.message || '이미 사용 중인 이메일입니다.');
}
}
// 인증 완료 이벤트 리스너
document.addEventListener('authVerified', function () {
isAuthVerified = true;
});
// 인증 완료 이벤트 리스너
document.addEventListener('authVerified', function () {
isAuthVerified = true;
// 취소 버튼 클릭 시
document.getElementById('cancelButton').addEventListener('click', function () {
location.href = '/login';
});
});
// 취소 버튼 클릭 시
document.getElementById('cancelButton').addEventListener('click', function () {
location.href = '/login';
});
});
</script>
</th:block>
</script>
</th:block>
</body>
</html>
</html>
@@ -24,25 +24,24 @@
<h3>초대 정보</h3>
</div>
<div class="account-recovery-card" style="margin-bottom: 32px;">
<div class="account-recovery-result">
<div class="result-header">
<i class="fas fa-envelope-open-text result-icon" style="color: #1a73e8;"></i>
<h2 class="result-title" style="font-size: 18px;">
<strong th:text="${userName}"></strong>님, 법인회원 초대가 도착했습니다.
</h2>
<div class="invitation_alert_banner">
<div class="banner-content-wrapper">
<div class="banner-icon-area">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="#E8F1FF" stroke="#1976D2" stroke-width="2"/>
<path d="M12 8V13" stroke="#1976D2" stroke-width="2" stroke-linecap="round"/>
<circle cx="12" cy="16" r="1.25" fill="#1976D2"/>
</svg>
</div>
<div class="result-info-box">
<p class="info-text">
<strong th:text="${orgName}"></strong>에서
DJBank API Portal 법인회원으로 초대하였습니다.
</p>
<p class="info-text" style="margin-top: 8px;">
<div class="banner-text-area">
<strong>
<span th:text="${userName}"></span>님, 법인회원 초대가 도착했습니다.
</strong>
<p>
<strong><span th:text="${orgName}"></span></strong>에서 DJBank API Portal 법인회원으로 초대하였습니다.<br>
초대를 수락하시면 법인회원으로 전환됩니다.
</p>
</div>
</div>
</div>
@@ -1,50 +1,62 @@
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
<body>
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>회원가입</h1>
</div>
</section>
<th:block layout:fragment="contentFragment">
<div class="account-recovery-page">
<div class="account-recovery-container">
<div class="account-recovery-card">
<!-- Result Content Area -->
<div class="account-recovery-result">
<div class="result-header">
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<rect width="64" height="64" fill="url(#pattern0_1130_6799)"/>
<defs>
<pattern id="pattern0_1130_6799" patternContentUnits="objectBoundingBox" width="1" height="1">
<use xlink:href="#image0_1130_6799" transform="translate(0 0.104294) scale(0.00613497)"/>
</pattern>
<image id="image0_1130_6799" width="163" height="129" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAKMAAACBCAYAAACsCAq9AAAACXBIWXMAABcSAAAXEgFnn9JSAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAACtpJREFUeNrsnUFoY1UUhm8GXYgwFmcxG2FSBZEBp6m60IU0AREGhCZrF003umzrxpW20Y1uTAtudJN04bodGBBEaLrShdoMwiAI9g24mYUQBXXhxnuSk87rTJPc8969L/e+9x94lOkkzXt53/vPOfeee65SMJgnVsJXcN6++emXsv5BR0UfCxNe1tfH4M2XXujhGwOMtuGr62NFH9UpAKopYNJxSx89DegAWAFGCYAEXFMfa6yANq2rj32oJmA0gbDNSrjg+OMifbQ0lF1gBhgfhnBTHxsZQHiRG9+CUgJGApHiwA4nJfO0XVZKxJRFhFGDuKN/bHt0SuS6GxrIPtArCIzslg84O/bR1hFLPmqXcgrikccgknVYtWF5VcYYiJVATrmrFXIdGOZMGQMEkaypz3sTGObPTdsCMeLMlxSrRodWL/Igi+N/U1asj0N92MiM2xrIJlDMiZvWN5MGstMoDEFFCcWeBi8SfjYNoNNMTj3l59eKnmWXcgBinTPnpBDskRKmHf/jOe5OisSpr89hGTCGHSeeqmSzKuRm120PQvPD0Ul4TjQovgMYi+WeaXpu19NkalEaKgDG+YNYZlWUWmYDzvocSSGlyQmVodWQTYdl2z6DSMZjiNLPq/J8OmAMSBWbPoMYDwnUqHJHYhuAMRyT3qzDec0Fc4LUULIxyTo/cIAxAJOoIkEw1yk3TkhawrfVAaP/Llpapb3lQw0hZ+8Sd70GGP23FcFrI89KtSTqWCmaqw4RRkmmuefTiesHgwbaI0fXChgzdtHkniUDyV0PL0PygKwARn9NAqKva5h7jq4XMHoM47GPF8CVORFgDB9GSRbd8/g6jONGDk0Ao4e25OKGz8Ekql0BjIErY1ErXwAjDAYYYYARBgOMoiy0jNsLGF3aPcFrfYZRMrPSB4x+mmRGperxdRgP1xSpa1loMEpUYtXHC9Dhw7Re4Q9bD27aUxM23PS1BEtSpxgBxvyoY9PD85dUcB8DRr9Noo5eVUtzT52yo2sFjHOwfUlG7VkfRMny2n7RpjSDg1FYgkW24UPsyA9F2dFDBxgDUUfKXDseZNDSpgNdwBiGSfvkVOflrmP9xUUgFnFXhCBh5BslVY7trJtyxhpAScOEliqghVwoQTdMqh6drIBM0YmsW9RazGBh5BuWZClqh1vpuY4Rk4A4KKoqkuWhc+2pSlYUQVn5uu3WxdwwflslaxbqtG8kYMwmUz1J8Sco9myldY3cxq6tkq9ZKWxfxtzAyCDsqPTbshGUt7jrg+nnkiKPG8ynWThF7nm56Ot2crMpUcIusZPAINdN88KRenSAnaC7pkYlarZW7tWw62q+YAxxUyKlsI9g/mAMFEiAmFcYAwMSIOYdRgcxpG0bMIiHwO+85XapKu80sKXs7O9ny/qcNQPEIiljTCHJXbfV/BdoFXr3K8B4HsomQ5l1V68eu+UIuAHGh5Mbmq6jQepyBhC2MH4IGE2Vkpaz2tzigtSP4sE9KCFgTKqWVQazomRDQvHZmsOi7xcNGN0lPQvsyssXuN9hZlzEamwYDAaDwU27jxErMRd8LeaKq8JYUfHPP/nnANkzYJyVnBB8K/wzi3HGiOG8o0ZFswC0iDByxfWqsltvaMMIyFucdUdAL6cw8k6r4zHDEPZNIRgxHpkXGLnkv6mymU1xaeTO91gxB4AxPDe8pvwsD0tjA4aysOumS4FBuK2Kse1tV1lYsQgY3bjjtrI7hxySUu4WxX2XPIZwXGGzrYptFFM2iqCSlzwFkVzxCUAcGg1PHRThQksegthmRZyHW5RU3WQduzbyvlzhMc9iwwPlfqC6zwdtcNRToym8forzHs/qEJxL6sFUowuFzDWMJU9ArDKIC47go5mPzKblYtOQK5x42YAzXS+em7fjU6NLse+6GvuexonS8dlD+/VbUWFg5Ipr222OCbp9voGRB9dYVul78sgXdN28beNzxw9z1zWYpTnfJJtrmyMG0OtBY3brG0o+dWnej2cE4bayPzFAn9/SUPZyBaNFECNWjW5I8VFs6GrDAEozF+0OwougXLetlKWAQQwSwgRQ9jiTHswAMU2T0qRGKrkTLIwWQBzOTORtQTxDSd/Lauxhm90vcpSYdNT8ZqgopqxpKAdBwWihqefQPaDs6hyIPjS5GjCQ/SBgTJk1D9gl74LAMxDHTex9qd9MDWQpIxDT9N120ggeiugfkKUMQKQv7jThE2wWvANEn4xCqOUkMWQW04FJZ1a63NYOdt7ayu9GqGW+5+LZIqdVO7wnShUgWlPFugqjwr3KQ01+uOkUcSJAnOyeT1Q4630G7K6NRz5cKmMHIFq1TRXWwjN6eETDeE6Ukd2zdH8+Wh3XAHMTVTFpEjhvWzRVx0sOQBQ/EYqHb0DdVFVcCPTcjVlw4aalrYrH3f8xfDPZ1gI+9yYre7YwxhbWSwwD2rMzaC9ixYUnH1fVG1dU5dnL0rcazZvbHmeUJi2H2IZipq14IW9vPKPa714fAknW+/kP1fjoRzX4+z+Tt1PxRzczZeSlA1WpewZrM63qA4id95bOQBye1ItXhr+zqYw23bQ0aUGcaJZFz3W2Zeft5ydCV3/tquRaZl6HFTfNsaLkCe7BPRvZXEEkCEkVLV5LPwtllKriFjhzA+M4ycgCxO63v0v+5MwkLLUyxiqUTa2L7NmcLcmL2+9cV5v1xVFArhOLrS/uSoEZwnzw4cvDmHCa9X/7a/j3BXYtCzfdFL6+BcbcxHZjEMdQjWM9UyDpPUefvjpz6IZArL3/vWkmbayMNtz0hlAVI6Bj31ZuPD3R3ZokGqYgEtgJQDSyVDDycE5Z8Baooit/Hht2uQjIaZDR/512a0Ygrn92xwmINpRRMk3Vgyq6M3KdSVSPfkf/Nw3mOIguLS2MkuWRe0DGnVEyYQJk+eoTZ7+jrNsERILQAojHzmDkHQZMs70I44rJBM/0heQ6KZabBeTBB68Mf9KwzdEnZiBKM/J5KOOq4LUAMZmJwhoCsvHxD1NjOnLLJ5+/PnMqj/6GZRB7LmGsCl67D64S2GjJp2jKNLr/78xsN+6qp6msZUXsO4GR17eYZtERBrndKspFyUzS4RcTd59I4Q2WriZVRokqwkU7DvwnASlNOsYQWwbRmIGkMEpq7OCiM7iRF77xu/vGQDoE0ZgB18o4gItOHTdGaYA0GR9M49YNXXTfCYyxhupO4h3YhZZqjJaA3D08nfh/DkEkM551S6KMkrKmY3BkRR17aR/srS/vPgIk/dvl9B6rYtf0xUmqdpYEr4WLtqsw1bRAtr76VVWeu6yi+/8Mh4EyOGdjEy/i1276yPRL0fFiCQxZtJu3bTbkd209rYqi5k9J3LTp0wlVtG9UIR/CuqFEi+1EMPJaF7jo+cWOdJMbQTw0CXZCkCqjBMZ7oMdZMuPzEt9dSdKSBkZJJg1ldAdkV/lZqEy7aCVebCeFUdpDB+YOyB3PgCQQUym2FManoIzeAemDy26lBdGpm0a3iExd9rIS1j5azJobtnbJksIY4e57CWSfgcxynxx6CKgRqLWqLCmMpjVJKBvLHsgBJw+Ljr9/yuZrQ7dsYYu2uIlmSAR7utSy2mgcNsEe7LAq3Up4mhLuu9reVwwjAzlrmzA0ifcPTAKSalCrgri/rx5sfN6zrYJWYIwBOX7q4iffwirAIOAclwHGW+49WG/jUP2m2f8CDAD1bzaEOOLAWAAAAABJRU5ErkJggg=="/>
</defs>
</svg>
<h2 class="result-title" th:text="${registrationType == 'corporate'} ? '법인 신청이 완료 되었습니다.' : '회원가입이 완료되었습니다.'">회원가입이 완료되었습니다.</h2>
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>회원가입</h1>
</div>
</section>
<th:block layout:fragment="contentFragment">
<div class="account-recovery-page">
<div class="account-recovery-container">
<div class="account-recovery-card">
<!-- Result Content Area -->
<div class="account-recovery-result">
<div class="result-header">
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink">
<rect width="64" height="64" fill="url(#pattern0_1130_6799)" />
<defs>
<pattern id="pattern0_1130_6799" patternContentUnits="objectBoundingBox" width="1" height="1">
<use xlink:href="#image0_1130_6799" transform="translate(0 0.104294) scale(0.00613497)" />
</pattern>
<image id="image0_1130_6799" width="163" height="129" preserveAspectRatio="none"
xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAKMAAACBCAYAAACsCAq9AAAACXBIWXMAABcSAAAXEgFnn9JSAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAACtpJREFUeNrsnUFoY1UUhm8GXYgwFmcxG2FSBZEBp6m60IU0AREGhCZrF003umzrxpW20Y1uTAtudJN04bodGBBEaLrShdoMwiAI9g24mYUQBXXhxnuSk87rTJPc8969L/e+9x94lOkkzXt53/vPOfeee65SMJgnVsJXcN6++emXsv5BR0UfCxNe1tfH4M2XXujhGwOMtuGr62NFH9UpAKopYNJxSx89DegAWAFGCYAEXFMfa6yANq2rj32oJmA0gbDNSrjg+OMifbQ0lF1gBhgfhnBTHxsZQHiRG9+CUgJGApHiwA4nJfO0XVZKxJRFhFGDuKN/bHt0SuS6GxrIPtArCIzslg84O/bR1hFLPmqXcgrikccgknVYtWF5VcYYiJVATrmrFXIdGOZMGQMEkaypz3sTGObPTdsCMeLMlxSrRodWL/Igi+N/U1asj0N92MiM2xrIJlDMiZvWN5MGstMoDEFFCcWeBi8SfjYNoNNMTj3l59eKnmWXcgBinTPnpBDskRKmHf/jOe5OisSpr89hGTCGHSeeqmSzKuRm120PQvPD0Ul4TjQovgMYi+WeaXpu19NkalEaKgDG+YNYZlWUWmYDzvocSSGlyQmVodWQTYdl2z6DSMZjiNLPq/J8OmAMSBWbPoMYDwnUqHJHYhuAMRyT3qzDec0Fc4LUULIxyTo/cIAxAJOoIkEw1yk3TkhawrfVAaP/Llpapb3lQw0hZ+8Sd70GGP23FcFrI89KtSTqWCmaqw4RRkmmuefTiesHgwbaI0fXChgzdtHkniUDyV0PL0PygKwARn9NAqKva5h7jq4XMHoM47GPF8CVORFgDB9GSRbd8/g6jONGDk0Ao4e25OKGz8Ekql0BjIErY1ErXwAjDAYYYYARBgOMoiy0jNsLGF3aPcFrfYZRMrPSB4x+mmRGperxdRgP1xSpa1loMEpUYtXHC9Dhw7Re4Q9bD27aUxM23PS1BEtSpxgBxvyoY9PD85dUcB8DRr9Noo5eVUtzT52yo2sFjHOwfUlG7VkfRMny2n7RpjSDg1FYgkW24UPsyA9F2dFDBxgDUUfKXDseZNDSpgNdwBiGSfvkVOflrmP9xUUgFnFXhCBh5BslVY7trJtyxhpAScOEliqghVwoQTdMqh6drIBM0YmsW9RazGBh5BuWZClqh1vpuY4Rk4A4KKoqkuWhc+2pSlYUQVn5uu3WxdwwflslaxbqtG8kYMwmUz1J8Sco9myldY3cxq6tkq9ZKWxfxtzAyCDsqPTbshGUt7jrg+nnkiKPG8ynWThF7nm56Ot2crMpUcIusZPAINdN88KRenSAnaC7pkYlarZW7tWw62q+YAxxUyKlsI9g/mAMFEiAmFcYAwMSIOYdRgcxpG0bMIiHwO+85XapKu80sKXs7O9ny/qcNQPEIiljTCHJXbfV/BdoFXr3K8B4HsomQ5l1V68eu+UIuAHGh5Mbmq6jQepyBhC2MH4IGE2Vkpaz2tzigtSP4sE9KCFgTKqWVQazomRDQvHZmsOi7xcNGN0lPQvsyssXuN9hZlzEamwYDAaDwU27jxErMRd8LeaKq8JYUfHPP/nnANkzYJyVnBB8K/wzi3HGiOG8o0ZFswC0iDByxfWqsltvaMMIyFucdUdAL6cw8k6r4zHDEPZNIRgxHpkXGLnkv6mymU1xaeTO91gxB4AxPDe8pvwsD0tjA4aysOumS4FBuK2Kse1tV1lYsQgY3bjjtrI7hxySUu4WxX2XPIZwXGGzrYptFFM2iqCSlzwFkVzxCUAcGg1PHRThQksegthmRZyHW5RU3WQduzbyvlzhMc9iwwPlfqC6zwdtcNRToym8forzHs/qEJxL6sFUowuFzDWMJU9ArDKIC47go5mPzKblYtOQK5x42YAzXS+em7fjU6NLse+6GvuexonS8dlD+/VbUWFg5Ipr222OCbp9voGRB9dYVul78sgXdN28beNzxw9z1zWYpTnfJJtrmyMG0OtBY3brG0o+dWnej2cE4bayPzFAn9/SUPZyBaNFECNWjW5I8VFs6GrDAEozF+0OwougXLetlKWAQQwSwgRQ9jiTHswAMU2T0qRGKrkTLIwWQBzOTORtQTxDSd/Lauxhm90vcpSYdNT8ZqgopqxpKAdBwWihqefQPaDs6hyIPjS5GjCQ/SBgTJk1D9gl74LAMxDHTex9qd9MDWQpIxDT9N120ggeiugfkKUMQKQv7jThE2wWvANEn4xCqOUkMWQW04FJZ1a63NYOdt7ayu9GqGW+5+LZIqdVO7wnShUgWlPFugqjwr3KQ01+uOkUcSJAnOyeT1Q4630G7K6NRz5cKmMHIFq1TRXWwjN6eETDeE6Ukd2zdH8+Wh3XAHMTVTFpEjhvWzRVx0sOQBQ/EYqHb0DdVFVcCPTcjVlw4aalrYrH3f8xfDPZ1gI+9yYre7YwxhbWSwwD2rMzaC9ixYUnH1fVG1dU5dnL0rcazZvbHmeUJi2H2IZipq14IW9vPKPa714fAknW+/kP1fjoRzX4+z+Tt1PxRzczZeSlA1WpewZrM63qA4id95bOQBye1ItXhr+zqYw23bQ0aUGcaJZFz3W2Zeft5ydCV3/tquRaZl6HFTfNsaLkCe7BPRvZXEEkCEkVLV5LPwtllKriFjhzA+M4ycgCxO63v0v+5MwkLLUyxiqUTa2L7NmcLcmL2+9cV5v1xVFArhOLrS/uSoEZwnzw4cvDmHCa9X/7a/j3BXYtCzfdFL6+BcbcxHZjEMdQjWM9UyDpPUefvjpz6IZArL3/vWkmbayMNtz0hlAVI6Bj31ZuPD3R3ZokGqYgEtgJQDSyVDDycE5Z8Baooit/Hht2uQjIaZDR/512a0Ygrn92xwmINpRRMk3Vgyq6M3KdSVSPfkf/Nw3mOIguLS2MkuWRe0DGnVEyYQJk+eoTZ7+jrNsERILQAojHzmDkHQZMs70I44rJBM/0heQ6KZabBeTBB68Mf9KwzdEnZiBKM/J5KOOq4LUAMZmJwhoCsvHxD1NjOnLLJ5+/PnMqj/6GZRB7LmGsCl67D64S2GjJp2jKNLr/78xsN+6qp6msZUXsO4GR17eYZtERBrndKspFyUzS4RcTd59I4Q2WriZVRokqwkU7DvwnASlNOsYQWwbRmIGkMEpq7OCiM7iRF77xu/vGQDoE0ZgB18o4gItOHTdGaYA0GR9M49YNXXTfCYyxhupO4h3YhZZqjJaA3D08nfh/DkEkM551S6KMkrKmY3BkRR17aR/srS/vPgIk/dvl9B6rYtf0xUmqdpYEr4WLtqsw1bRAtr76VVWeu6yi+/8Mh4EyOGdjEy/i1276yPRL0fFiCQxZtJu3bTbkd209rYqi5k9J3LTp0wlVtG9UIR/CuqFEi+1EMPJaF7jo+cWOdJMbQTw0CXZCkCqjBMZ7oMdZMuPzEt9dSdKSBkZJJg1ldAdkV/lZqEy7aCVebCeFUdpDB+YOyB3PgCQQUym2FManoIzeAemDy26lBdGpm0a3iExd9rIS1j5azJobtnbJksIY4e57CWSfgcxynxx6CKgRqLWqLCmMpjVJKBvLHsgBJw+Ljr9/yuZrQ7dsYYu2uIlmSAR7utSy2mgcNsEe7LAq3Up4mhLuu9reVwwjAzlrmzA0ifcPTAKSalCrgri/rx5sfN6zrYJWYIwBOX7q4iffwirAIOAclwHGW+49WG/jUP2m2f8CDAD1bzaEOOLAWAAAAABJRU5ErkJggg==" />
</defs>
</svg>
<h2 class="result-title"
th:text="${registrationType == 'corporate'} ? '법인 신청이 완료 되었습니다.' : '회원가입이 완료되었습니다.'">회원가입이 완료되었습니다.</h2>
</div>
<!-- Info Box -->
<div class="result-info-box info-box-highlight" th:if="${registrationType == 'corporate'}">
<div class="highlight-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="#E8F1FF" stroke="#0049B4" stroke-width="2" />
<path d="M12 8V13" stroke="#0049B4" stroke-width="2" stroke-linecap="round" />
<circle cx="12" cy="16" r="1.25" fill="#0049B4" />
</svg>
</div>
<p class="info-text">
<strong>관리자의 확인 및 승인</strong> 이후
이용하실 수 있습니다.
</p>
</div>
</div>
<!-- Info Box -->
<div class="result-info-box" th:if="${registrationType == 'corporate'}">
<p class="info-text">
관리자의 확인 및 승인 이후<br>
이용하실 수 있습니다.
</p>
<!-- Action Buttons -->
<div class="form-actions">
<a th:href="@{/}" class="submit-button">홈으로</a>
</div>
</div>
<!-- Action Buttons -->
<div class="form-actions">
<a th:href="@{/}" class="submit-button">홈으로</a>
</div>
</div>
</div>
</div>
</th:block>
</th:block>
</body>
<th:block layout:fragment="contentScript">
</th:block>
</html>
</html>
@@ -37,181 +37,244 @@
<img th:src="@{/img/icon/icon_check_green.png}" alt="체크 아이콘" />
</div>
<ul class="signup-info-list">
<li>회원은 법인, 개인(개발자)분으로 구분되며, 개발자 포털에서 온라인으로 회원 가입 신청을 합니다.</li>
<li>포털 사용은 운영 담당자의 승인 후 가능합니다.</li>
<li>법인 관리자는 회원 승인 이후, 포털을 실제 사용할 직원(개발자)을 추가 등록해 주시기 바랍니다.</li>
<li>회원은 <strong>법인, 개인(개발자)</strong>분으로 구분되며, 개발자 포털에서 온라인으로 회원 가입 신청을 합니다.</li>
<li>포털 사용은 <strong>운영 담당자의 승인 후</strong> 가능합니다.</li>
<li>법인 관리자는 회원 승인 이후, 포털을 실제 사용할 <strong>직원(개발자)을 추가 등록</strong>해 주시기 바랍니다.</li>
</ul>
</div>
<!-- Vertical Timeline Steps -->
<div class="signup-timeline">
<!-- Step 1 -->
<div class="signup-step">
<div class="signup-step__icon-box one">
<!--/* <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M14 20C14 19.4477 14.4477 19 15 19H17C17.5523 19 18 19.4477 18 20V24C18 24.5523 17.5523 25 17 25H15C14.4477 25 14 24.5523 14 24V20Z"
stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
stroke-linejoin="round" />
<path
d="M11 13C11 11.3431 12.3431 10 14 10C15.6569 10 17 11.3431 17 13C17 14.6569 15.6569 16 14 16C12.3431 16 11 14.6569 11 13Z"
stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M20 21V20C20 18.8954 20.8954 18 22 18C23.1046 18 24 18.8954 24 20V21"
stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
stroke-linejoin="round" />
<path
d="M19 14C19 12.8954 19.8954 12 21 12C22.1046 12 23 12.8954 23 14C23 15.1046 22.1046 16 21 16C19.8954 16 19 15.1046 19 14Z"
stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
stroke-linejoin="round" />
</svg> */-->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-person-add" viewBox="0 0 16 16">
<path
d="M12.5 16a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7m.5-5v1h1a.5.5 0 0 1 0 1h-1v1a.5.5 0 0 1-1 0v-1h-1a.5.5 0 0 1 0-1h1v-1a.5.5 0 0 1 1 0m-2-6a3 3 0 1 1-6 0 3 3 0 0 1 6 0M8 7a2 2 0 1 0 0-4 2 2 0 0 0 0 4" />
<path
d="M8.256 14a4.5 4.5 0 0 1-.229-1.004H3c.001-.246.154-.986.832-1.664C4.484 10.68 5.711 10 8 10q.39 0 .74.025c.226-.341.496-.65.804-.918Q8.844 9.002 8 9c-5 0-6 3-6 4s1 1 1 1z" />
</svg>
</div>
<div class="signup-step__card">
<div class="signup-step__card-num">STEP 01</div>
<div class="signup-step__card-title">회원가입</div>
<!-- Member Type Tabs -->
<div class="signup-tabs">
<button type="button" class="signup-tab-btn active" data-tab="personal">개인 회원</button>
<button type="button" class="signup-tab-btn" data-tab="corporate">법인 회원</button>
</div>
<!-- Timeline Container for fixed height spacing -->
<div class="signup-timeline-container">
<!-- Personal Member Timeline -->
<div class="signup-timeline active" id="timeline-personal">
<!-- Step 1 -->
<div class="signup-step">
<div class="signup-step__icon-box one">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-person-add" viewBox="0 0 16 16">
<path
d="M12.5 16a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7m.5-5v1h1a.5.5 0 0 1 0 1h-1v1a.5.5 0 0 1-1 0v-1h-1a.5.5 0 0 1 0-1h1v-1a.5.5 0 0 1 1 0m-2-6a3 3 0 1 1-6 0 3 3 0 0 1 6 0M8 7a2 2 0 1 0 0-4 2 2 0 0 0 0 4" />
<path
d="M8.256 14a4.5 4.5 0 0 1-.229-1.004H3c.001-.246.154-.986.832-1.664C4.484 10.68 5.711 10 8 10q.39 0 .74.025c.226-.341.496-.65.804-.918Q8.844 9.002 8 9c-5 0-6 3-6 4s1 1 1 1z" />
</svg>
</div>
<div class="signup-step__card">
<div class="signup-step__card-header">
<span class="signup-step__card-num">STEP 01</span>
<span class="signup-step__card-dot"></span>
<span class="signup-step__card-title">회원가입</span>
</div>
<p class="signup-step__card-desc">개발자 포털을 이용하기 위해 회원가입을 진행합니다.</p>
</div>
</div>
</div>
<!-- Step 2 -->
<div class="signup-step">
<div class="signup-step__icon-box two">
<!--/* <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M14 20C14 19.4477 14.4477 19 15 19H17C17.5523 19 18 19.4477 18 20V24C18 24.5523 17.5523 25 17 25H15C14.4477 25 14 24.5523 14 24V20Z"
stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
stroke-linejoin="round" />
<path
d="M11 13C11 11.3431 12.3431 10 14 10C15.6569 10 17 11.3431 17 13C17 14.6569 15.6569 16 14 16C12.3431 16 11 14.6569 11 13Z"
stroke="#0049B4" stroke -width="1.6" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M19 16L21 18L25 14" stroke="#0049B4" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round" />
</svg> */-->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-journal-check" viewBox="0 0 16 16">
<path fill-rule="evenodd"
d="M10.854 6.146a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7.5 8.793l2.646-2.647a.5.5 0 0 1 .708 0" />
<path
d="M3 0h10a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2v-1h1v1a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v1H1V2a2 2 0 0 1 2-2" />
<path
d="M1 5v-.5a.5.5 0 0 1 1 0V5h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1zm0 3v-.5a.5.5 0 0 1 1 0V8h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1zm0 3v-.5a.5.5 0 0 1 1 0v.5h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1z" />
</svg>
<!-- Corporate Member Timeline -->
<div class="signup-timeline" id="timeline-corporate">
<!-- Step 1 -->
<div class="signup-step">
<div class="signup-step__icon-box one">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-person-add" viewBox="0 0 16 16">
<path
d="M12.5 16a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7m.5-5v1h1a.5.5 0 0 1 0 1h-1v1a.5.5 0 0 1-1 0v-1h-1a.5.5 0 0 1 0-1h1v-1a.5.5 0 0 1 1 0m-2-6a3 3 0 1 1-6 0 3 3 0 0 1 6 0M8 7a2 2 0 1 0 0-4 2 2 0 0 0 0 4" />
<path
d="M8.256 14a4.5 4.5 0 0 1-.229-1.004H3c.001-.246.154-.986.832-1.664C4.484 10.68 5.711 10 8 10q.39 0 .74.025c.226-.341.496-.65.804-.918Q8.844 9.002 8 9c-5 0-6 3-6 4s1 1 1 1z" />
</svg>
</div>
<div class="signup-step__card">
<div class="signup-step__card-header">
<span class="signup-step__card-num">STEP 01</span>
<span class="signup-step__card-dot"></span>
<span class="signup-step__card-title">회원가입</span>
</div>
<p class="signup-step__card-desc">개발자 포털을 이용하기 위해 회원가입을 진행합니다.</p>
</div>
</div>
<div class="signup-step__card">
<div class="signup-step__card-num">STEP 02</div>
<div class="signup-step__card-title">승인</div>
</div>
</div>
<!-- Step 3 -->
<div class="signup-step">
<div class="signup-step__icon-box three">
<!--/* <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
xmlns="http://www.w3.org/2000/svg">
<rect x="7" y="7" width="18" height="18" rx="2" stroke="#0049B4" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round" />
<path d="M12 12H20" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
<path d="M12 16H20" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
<path d="M12 20H16" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
</svg> */-->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-code" viewBox="0 0 16 16">
<path
d="M5.854 4.854a.5.5 0 1 0-.708-.708l-3.5 3.5a.5.5 0 0 0 0 .708l3.5 3.5a.5.5 0 0 0 .708-.708L2.707 8zm4.292 0a.5.5 0 0 1 .708-.708l3.5 3.5a.5.5 0 0 1 0 .708l-3.5 3.5a.5.5 0 0 1-.708-.708L13.293 8z" />
</svg>
<!-- Step 2 -->
<div class="signup-step">
<div class="signup-step__icon-box two">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-journal-check" viewBox="0 0 16 16">
<path fill-rule="evenodd"
d="M10.854 6.146a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7.5 8.793l2.646-2.647a.5.5 0 0 1 .708 0" />
<path
d="M3 0h10a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2v-1h1v1a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v1H1V2a2 2 0 0 1 2-2" />
<path
d="M1 5v-.5a.5.5 0 0 1 1 0V5h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1zm0 3v-.5a.5.5 0 0 1 1 0V8h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1zm0 3v-.5a.5.5 0 0 1 1 0v.5h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1z" />
</svg>
</div>
<div class="signup-step__card">
<div class="signup-step__card-header">
<span class="signup-step__card-num">STEP 02</span>
<span class="signup-step__card-dot"></span>
<span class="signup-step__card-title">승인</span>
</div>
<p class="signup-step__card-desc">신청하신 계정 정보를 관리자가 확인 후 승인합니다.</p>
</div>
</div>
<div class="signup-step__card">
<div class="signup-step__card-num">STEP 03</div>
<div class="signup-step__card-title">API/APP 사용 신청</div>
</div>
</div>
<!-- Step 4 -->
<div class="signup-step">
<div class="signup-step__icon-box four">
<!--/* <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M21 8H24V16" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M8 8H11V16" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
stroke-linejoin="round" />
</svg> */-->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-display" viewBox="0 0 16 16">
<path
d="M0 4s0-2 2-2h12s2 0 2 2v6s0 2-2 2h-4q0 1 .25 1.5H11a.5.5 0 0 1 0 1H5a.5.5 0 0 1 0-1h.75Q6 13 6 12H2s-2 0-2-2zm1.398-.855a.76.76 0 0 0-.254.302A1.5 1.5 0 0 0 1 4.01V10c0 .325.078.502.145.602q.105.156.302.254a1.5 1.5 0 0 0 .538.143L2.01 11H14c.325 0 .502-.078.602-.145a.76.76 0 0 0 .254-.302 1.5 1.5 0 0 0 .143-.538L15 9.99V4c0-.325-.078-.502-.145-.602a.76.76 0 0 0-.302-.254A1.5 1.5 0 0 0 13.99 3H2c-.325 0-.502.078-.602.145" />
</svg>
<!-- Step 3 -->
<div class="signup-step">
<div class="signup-step__icon-box three">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-code" viewBox="0 0 16 16">
<path
d="M5.854 4.854a.5.5 0 1 0-.708-.708l-3.5 3.5a.5.5 0 0 0 0 .708l3.5 3.5a.5.5 0 0 0 .708-.708L2.707 8zm4.292 0a.5.5 0 0 1 .708-.708l3.5 3.5a.5.5 0 0 1 0 .708l-3.5 3.5a.5.5 0 0 1-.708-.708L13.293 8z" />
</svg>
</div>
<div class="signup-step__card">
<div class="signup-step__card-header">
<span class="signup-step__card-num">STEP 03</span>
<span class="signup-step__card-dot"></span>
<span class="signup-step__card-title">API / APP 사용 신청</span>
</div>
<p class="signup-step__card-desc">사용할 API를 선택하고 테스트용 키를 발급받습니다.</p>
</div>
</div>
<div class="signup-step__card">
<div class="signup-step__card-num">STEP 04</div>
<div class="signup-step__card-title">개발/테스트</div>
</div>
</div>
<!-- Step 5 -->
<div class="signup-step">
<div class="signup-step__icon-box five">
<!--/* <svg width="40" height="40" viewBox="0 0 32 32" fill="none"*/-->
<!-- xmlns="http://www.w3.org/2000/svg">-->
<!--/* <rect x="7" y="7" width="18" height="18" rx="2" stroke="#0049B4" stroke-width="1.6"*/-->
<!-- stroke-linecap="round" stroke-linejoin="round" />-->
<!--/* <path d="M12 11H20" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />*/-->
<!--/* <path d="M12 15L14.5 17.5L20 12" stroke="#0049B4" stroke-width="1.6"*/-->
<!-- stroke-linecap="round" stroke-linejoin="round" />-->
<!--/* </svg>*/-->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-ui-checks-grid" viewBox="0 0 16 16">
<path
d="M2 10h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1m9-9h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1m0 9a1 1 0 0 0-1 1v3a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1zm0-10a2 2 0 0 0-2 2v3a2 2 0 0 0 2 2h3a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zM2 9a2 2 0 0 0-2 2v3a2 2 0 0 0 2 2h3a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2zm7 2a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2h-3a2 2 0 0 1-2-2zM0 2a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2zm5.354.854a.5.5 0 1 0-.708-.708L3 3.793l-.646-.647a.5.5 0 1 0-.708.708l1 1a.5.5 0 0 0 .708 0z" />
</svg>
<!-- Step 4 -->
<div class="signup-step">
<div class="signup-step__icon-box four">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-display" viewBox="0 0 16 16">
<path
d="M0 4s0-2 2-2h12s2 0 2 2v6s0 2-2 2h-4q0 1 .25 1.5H11a.5.5 0 0 1 0 1H5a.5.5 0 0 1 0-1h.75Q6 13 6 12H2s-2 0-2-2zm1.398-.855a.76.76 0 0 0-.254.302A1.5 1.5 0 0 0 1 4.01V10c0 .325.078.502.145.602q.105.156.302.254a1.5 1.5 0 0 0 .538.143L2.01 11H14c.325 0 .502-.078.602-.145a.76.76 0 0 0 .254-.302 1.5 1.5 0 0 0 .143-.538L15 9.99V4c0-.325-.078-.502-.145-.602a.76.76 0 0 0-.302-.254A1.5 1.5 0 0 0 13.99 3H2c-.325 0-.502.078-.602.145" />
</svg>
</div>
<div class="signup-step__card">
<div class="signup-step__card-header">
<span class="signup-step__card-num">STEP 04</span>
<span class="signup-step__card-dot"></span>
<span class="signup-step__card-title">개발 / 테스트</span>
</div>
<p class="signup-step__card-desc">제공된 개발 도구로 자유롭게 연동을 테스트합니다.</p>
</div>
</div>
<div class="signup-step__card">
<div class="signup-step__card-num">STEP 05</div>
<div class="signup-step__card-title">운영신청 및 심사</div>
</div>
</div>
<!-- Step 6 -->
<div class="signup-step">
<div class="signup-step__icon-box six">
<!--/* <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
xmlns="http://www.w3.org/2000/svg">
<rect x="7" y="9" width="18" height="16" rx="2" stroke="#0049B4" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round" />
<path d="M7 13H25" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
<path d="M12 7V11" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
<path d="M20 7V11" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
<path d="M12 17H14" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
<path d="M12 21H14" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
</svg> */-->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-airplane" viewBox="0 0 16 16">
<path
d="M6.428 1.151C6.708.591 7.213 0 8 0s1.292.592 1.572 1.151C9.861 1.73 10 2.431 10 3v3.691l5.17 2.585a1.5 1.5 0 0 1 .83 1.342V12a.5.5 0 0 1-.582.493l-5.507-.918-.375 2.253 1.318 1.318A.5.5 0 0 1 10.5 16h-5a.5.5 0 0 1-.354-.854l1.319-1.318-.376-2.253-5.507.918A.5.5 0 0 1 0 12v-1.382a1.5 1.5 0 0 1 .83-1.342L6 6.691V3c0-.568.14-1.271.428-1.849m.894.448C7.111 2.02 7 2.569 7 3v4a.5.5 0 0 1-.276.447l-5.448 2.724a.5.5 0 0 0-.276.447v.792l5.418-.903a.5.5 0 0 1 .575.41l.5 3a.5.5 0 0 1-.14.437L6.708 15h2.586l-.647-.646a.5.5 0 0 1-.14-.436l.5-3a.5.5 0 0 1 .576-.411L15 11.41v-.792a.5.5 0 0 0-.276-.447L9.276 7.447A.5.5 0 0 1 9 7V3c0-.432-.11-.979-.322-1.401C8.458 1.159 8.213 1 8 1s-.458.158-.678.599" />
</svg>
<!-- Step 5 -->
<div class="signup-step">
<div class="signup-step__icon-box five">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-ui-checks-grid" viewBox="0 0 16 16">
<path
d="M2 10h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1m9-9h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1m0 9a1 1 0 0 0-1 1v3a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1zm0-10a2 2 0 0 0-2 2v3a2 2 0 0 0 2 2h3a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zM2 9a2 2 0 0 0-2 2v3a2 2 0 0 0 2 2h3a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2zm7 2a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2h-3a2 2 0 0 1-2-2zM0 2a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2zm5.354.854a.5.5 0 1 0-.708-.708L3 3.793l-.646-.647a.5.5 0 1 0-.708.708l1 1a.5.5 0 0 0 .708 0z" />
</svg>
</div>
<div class="signup-step__card">
<div class="signup-step__card-header">
<span class="signup-step__card-num">STEP 05</span>
<span class="signup-step__card-dot"></span>
<span class="signup-step__card-title">운영신청 및 심사</span>
</div>
<p class="signup-step__card-desc">실운영 환경 적용을 위해 최종 심사를 요청합니다.</p>
</div>
</div>
<div class="signup-step__card">
<div class="signup-step__card-num">STEP 06</div>
<div class="signup-step__card-title">서비스 개시(예약)</div>
<!-- Step 6 -->
<div class="signup-step">
<div class="signup-step__icon-box six">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-airplane" viewBox="0 0 16 16">
<path
d="M6.428 1.151C6.708.591 7.213 0 8 0s1.292.592 1.572 1.151C9.861 1.73 10 2.431 10 3v3.691l5.17 2.585a1.5 1.5 0 0 1 .83 1.342V12a.5.5 0 0 1-.582.493l-5.507-.918-.375 2.253 1.318 1.318A.5.5 0 0 1 10.5 16h-5a.5.5 0 0 1-.354-.854l1.319-1.318-.376-2.253-5.507.918A.5.5 0 0 1 0 12v-1.382a1.5 1.5 0 0 1 .83-1.342L6 6.691V3c0-.568.14-1.271.428-1.849m.894.448C7.111 2.02 7 2.569 7 3v4a.5.5 0 0 1-.276.447l-5.448 2.724a.5.5 0 0 0-.276.447v.792l5.418-.903a.5.5 0 0 1 .575.41l.5 3a.5.5 0 0 1-.14.437L6.708 15h2.586l-.647-.646a.5.5 0 0 1-.14-.436l.5-3a.5.5 0 0 1 .576-.411L15 11.41v-.792a.5.5 0 0 0-.276-.447L9.276 7.447A.5.5 0 0 1 9 7V3c0-.432-.11-.979-.322-1.401C8.458 1.159 8.213 1 8 1s-.458.158-.678.599" />
</svg>
</div>
<div class="signup-step__card">
<div class="signup-step__card-header">
<span class="signup-step__card-num">STEP 06</span>
<span class="signup-step__card-dot"></span>
<span class="signup-step__card-title">서비스 개시(예약)</span>
</div>
<p class="signup-step__card-desc">실운영 키를 적용하여 서비스를 정식 오픈합니다.</p>
</div>
</div>
</div>
</div>
<!-- 회원 구분별 이용 가능 서비스 (TBD) -->
<section class="signup-roles-tbd">
<h2 class="signup-roles-tbd__title">회원 구분별 이용 가능 서비스</h2>
<div class="signup-roles-tbd__placeholder">
<span class="signup-roles-tbd__badge">TBD</span>
<p class="signup-roles-tbd__desc">회원 구분별 이용 가능 서비스 안내가 준비 중입니다.</p>
<!-- 회원 구분별 이용 가능 서비스 -->
<section class="signup-roles-table-section">
<h2 class="signup-roles-table-section__title">회원 구분별 이용 가능 서비스</h2>
<div class="signup-roles-table-wrapper">
<table class="signup-roles-table">
<thead>
<tr>
<th rowspan="2" class="col-feature">기능</th>
<th rowspan="2" class="col-role">비회원</th>
<th rowspan="2" class="col-role">개인</th>
<th colspan="2" class="col-role-group">법인이용자</th>
</tr>
<tr>
<th class="col-sub-role">법인개발자</th>
<th class="col-sub-role">법인관리자</th>
</tr>
</thead>
<tbody>
<tr>
<td class="cell-feature">API 조회</td>
<td><span class="status-badge status-badge--allowed">O</span></td>
<td><span class="status-badge status-badge--allowed">O</span></td>
<td><span class="status-badge status-badge--allowed">O</span></td>
<td><span class="status-badge status-badge--allowed">O</span></td>
</tr>
<tr>
<td class="cell-feature">API 테스트</td>
<td><span class="status-badge status-badge--denied">X</span></td>
<td><span class="status-badge status-badge--allowed">O</span></td>
<td><span class="status-badge status-badge--allowed">O</span></td>
<td><span class="status-badge status-badge--allowed">O</span></td>
</tr>
<tr>
<td class="cell-feature">1:1 문의</td>
<td><span class="status-badge status-badge--denied">X</span></td>
<td><span class="status-badge status-badge--allowed">O</span></td>
<td><span class="status-badge status-badge--allowed">O</span></td>
<td><span class="status-badge status-badge--allowed">O</span></td>
</tr>
<tr>
<td class="cell-feature">피드백/개선요청</td>
<td><span class="status-badge status-badge--denied">X</span></td>
<td><span class="status-badge status-badge--allowed">O</span></td>
<td><span class="status-badge status-badge--allowed">O</span></td>
<td><span class="status-badge status-badge--allowed">O</span></td>
</tr>
<tr>
<td class="cell-feature">클라이언트 - 신규, 수정, 삭제</td>
<td><span class="status-badge status-badge--denied">X</span></td>
<td><span class="status-badge status-badge--denied">X</span></td>
<td><span class="status-badge status-badge--allowed">O</span></td>
<td><span class="status-badge status-badge--allowed">O</span></td>
</tr>
<tr>
<td class="cell-feature">Webhook 관리</td>
<td><span class="status-badge status-badge--denied">X</span></td>
<td><span class="status-badge status-badge--denied">X</span></td>
<td><span class="status-badge status-badge--denied">X</span></td>
<td><span class="status-badge status-badge--allowed">O</span></td>
</tr>
<tr>
<td class="cell-feature">이용 통계</td>
<td><span class="status-badge status-badge--denied">X</span></td>
<td><span class="status-badge status-badge--denied">X</span></td>
<td><span class="status-badge status-badge--allowed">O</span></td>
<td><span class="status-badge status-badge--allowed">O</span></td>
</tr>
<tr>
<td class="cell-feature">개발자 관리</td>
<td><span class="status-badge status-badge--denied">X</span></td>
<td><span class="status-badge status-badge--denied">X</span></td>
<td><span class="status-badge status-badge--denied">X</span></td>
<td><span class="status-badge status-badge--allowed">O</span></td>
</tr>
</tbody>
</table>
</div>
</section>
@@ -225,6 +288,31 @@
</body>
<th:block layout:fragment="contentScript">
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function () {
const tabs = document.querySelectorAll('.signup-tab-btn');
const timelines = document.querySelectorAll('.signup-timeline');
tabs.forEach(tab => {
tab.addEventListener('click', function () {
const targetTab = this.getAttribute('data-tab');
// Active Tab toggle
tabs.forEach(btn => btn.classList.remove('active'));
this.classList.add('active');
// Timeline display toggle
timelines.forEach(timeline => {
if (timeline.id === `timeline-${targetTab}`) {
timeline.classList.add('active');
} else {
timeline.classList.remove('active');
}
});
});
});
});
</script>
</th:block>
</html>
@@ -358,7 +358,7 @@ valid = constantTimeEquals(received, expected)</pre>
</tr>
<tr>
<td><code>ERROR_START</code></td>
<td>장애 시작</td>
<td>장애 발생</td>
</tr>
<tr>
<td><code>ERROR_END</code></td>
@@ -12,7 +12,7 @@
<th:block layout:fragment="contentFragment">
<!-- <link rel="stylesheet" type="text/css" th:href="@{/css/api-statistics.css}" /> -->
<div class="service-main app-management-layout">
<div class="service-main container" style="padding-top: 70px;">
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('statistics')}"></th:block>
<div class="app-management-content">
<section class="api-statistics-container">
@@ -24,7 +24,8 @@
<span th:if="${statsAggregationMinute == null}">통계 데이터는 매시 집계되며, 집계 이전 시간 기준으로 생성됩니다.</span>
<span class="statistics-notice-example" th:if="${statsAggregationMinute != null}"
th:text="'예) 매시 ' + ${statsAggregationMinute} + '분 집계 기준 — 현재 12:30이면 12:00 이전, 12:05이면 11:00 이전에 발생한 거래가 대상입니다.'">예시</span>
<span class="statistics-notice-example" th:if="${statsAggregationMinute == null}">예) 현재 12:30이면 12:00 이전, 12:05이면 11:00 이전에 발생한 거래가 대상입니다.</span>
<span class="statistics-notice-example" th:if="${statsAggregationMinute == null}">예) 현재 12:30이면 12:00 이전,
12:05이면 11:00 이전에 발생한 거래가 대상입니다.</span>
<span th:if="${statsLatestTime != null}" class="statistics-notice-latest"
th:text="'※ 최신 집계 시각: ' + ${statsLatestTime} + ' 기준'">최신 집계 시각</span>
</div>
@@ -13,8 +13,8 @@
</section>
<th:block layout:fragment="contentFragment">
<div class="signup-guide-v2 figma-register-wrapper">
<div class="service-main app-management-layout">
<div class="signup-guide-v2">
<div class="service-main container" style="padding-top: 70px;">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhook')}"></th:block>
@@ -22,9 +22,13 @@
<!-- Content Area -->
<div class="app-management-content">
<div class="step1-wrap">
<!-- Title -->
<h2 class="s1-title">Webhook 관리</h2>
<!-- Header Row with Title and Dev Guide Link -->
<div class="webhook-header-row">
<h2 class="s1-title">Webhook 관리</h2>
<a class="webhook-guide-btn" th:href="@{/service/webhook-dev-guide}">
웹훅 개발가이드 - 수신.서명검증.응답 규칙 보러가기
</a>
</div>
<!-- Empty Card -->
<div class="s1-form-card">
@@ -38,18 +42,11 @@
</p>
</div>
</div>
<!-- 액션 -->
<div class="s1-actions" sec:authorize="hasRole('ROLE_WEBHOOK')">
<button type="button" class="s1-btn-next" id="requestWebhook">Webhook 신청</button>
<button type="button" class="s1-btn-next-figma" id="requestWebhook">Webhook 신청</button>
</div>
<p class="webhook-guide-link">
<a th:href="@{/service/webhook-dev-guide}">
📘 웹훅 개발가이드 — 수신·서명검증·응답 규칙 보러가기
</a>
</p>
</div><!-- /step1-wrap -->
</div>
</div>
@@ -1,7 +1,6 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/djbank_title_layout}">
<body>
@@ -36,7 +35,8 @@
<div class="webhook-card-head">
<div class="head-title-group">
<!-- List Icon SVG -->
<svg class="head-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg class="head-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<line x1="8" y1="6" x2="21" y2="6"></line>
<line x1="8" y1="12" x2="21" y2="12"></line>
<line x1="8" y1="18" x2="21" y2="18"></line>
@@ -47,12 +47,13 @@
<h3>등록된 Webhook</h3>
</div>
<span class="webhook-created" th:if="*{createdDate != null and #strings.length(createdDate) >= 8}"
th:text="|등록일 : ${#strings.substring(webhook.createdDate,0,4)}-${#strings.substring(webhook.createdDate,4,6)}-${#strings.substring(webhook.createdDate,6,8)}|">등록일 : 2026-07-27</span>
th:text="|등록일 : ${#strings.substring(webhook.createdDate,0,4)}-${#strings.substring(webhook.createdDate,4,6)}-${#strings.substring(webhook.createdDate,6,8)}|">등록일
: 2026-07-27</span>
</div>
<!-- Vertical Content Fields -->
<div class="webhook-card-content">
<!-- 수신 URL -->
<div class="webhook-info-group">
<span class="group-label">수신 URL</span>
@@ -181,14 +182,14 @@
openModal('Secret 재발급',
'재발급 시 기존 Secret은 즉시 무효화됩니다. 새 Secret을 수신 서버의 Webhook 서명검증에 반영하지 않으면 이후 발송되는 모든 Webhook의 서명 검증이 실패합니다. 계속하려면 비밀번호를 입력하세요.',
function (pw) {
post('/webhook/regenerate-secret', pw).then(function (res) {
if (res.success) {
document.getElementById('secretMasked').textContent = res.secret;
closeModal();
alert('Secret이 재발급되었습니다.\n반드시 수신 서버의 서명검증 Secret을 새 값으로 교체하세요.\n교체 전까지 Webhook 서명 검증이 실패합니다.');
} else { showError(res.message || '실패했습니다.'); }
}).catch(function () { showError('요청 처리 중 오류가 발생했습니다.'); });
});
post('/webhook/regenerate-secret', pw).then(function (res) {
if (res.success) {
document.getElementById('secretMasked').textContent = res.secret;
closeModal();
alert('Secret이 재발급되었습니다.\n반드시 수신 서버의 서명검증 Secret을 새 값으로 교체하세요.\n교체 전까지 Webhook 서명 검증이 실패합니다.');
} else { showError(res.message || '실패했습니다.'); }
}).catch(function () { showError('요청 처리 중 오류가 발생했습니다.'); });
});
});
// 삭제
@@ -205,4 +206,5 @@
</script>
</th:block>
</body>
</html>
</html>
@@ -25,7 +25,7 @@
</div>
<div class="api-status" id="apiStatusPage"
th:attr="data-window-days=${windowDays},data-base=@{/apistatus},data-api-detail-base=@{/apis/detail}"
th:attr="data-window-days=${windowDays},data-base=@{/apistatus},data-api-detail-base=@{/apis/detail},data-notice-detail-base=@{/portalnotice/detail}"
th:classappend="${authenticated} ? 'is-authenticated' : ''">
<!-- ❶ 진행 중 장애 -->
@@ -22,6 +22,7 @@
th:attr="data-window-days=${windowDays},
data-base=@{/apistatus},
data-api-detail-base=@{/apis/detail},
data-notice-detail-base=@{/portalnotice/detail},
data-today=${today},
data-min-date=${minDate},
data-selected-date=${selectedDate} ?: '',
@@ -1,41 +1,42 @@
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<footer th:fragment="footerFragment" class="global-footer">
<div class="container">
<div class="footer-content">
<!-- Left Section -->
<div class="footer-left">
<img src="/img/logo/logo-jjb.png" alt="DJBank" class="footer-logo">
<div class="footer-links">
<a th:href="@{/agreements/terms}" class="footer-link">이용약관</a>
<span class="footer-separator"></span>
<a href="https://www.jejubank.co.kr/hmpg/csct/secuCenr/ptctPlcy/procsPlcy/ctnt.do"
target="_blank" rel="noopener noreferrer" class="footer-link footer-link--external">개인정보처리방침<svg class="footer-link-external-icon" width="14" height="14" viewBox="0 0 16 16" fill="none"
xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
<path d="M6.5 2.5H3.2A1.2 1.2 0 0 0 2 3.7v9.1A1.2 1.2 0 0 0 3.2 14h9.1a1.2 1.2 0 0 0 1.2-1.2V9.5"
stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.5 2h4.5v4.5M14 2 7.5 8.5" stroke="currentColor" stroke-width="1.3"
stroke-linecap="round" stroke-linejoin="round"/>
</svg><span class="sr-only">새 창으로 열림</span></a>
</div>
<p class="footer-copyright">Copyright &copy; 2026 JEJU Bank. All Rights Reserved.</p>
</div>
<!-- Right Section -->
<div class="footer-right">
<div class="footer-related-sites">
<select class="related-sites-select">
<option>DJBank 관련 사이트</option>
<option>DJBank 홈페이지</option>
<option>DJBank 인터넷뱅킹</option>
<option>DJBank 모바일뱅킹</option>
</select>
<body>
<footer th:fragment="footerFragment" class="global-footer">
<div class="container">
<div class="footer-content">
<!-- Left Section -->
<div class="footer-left">
<img src="/img/logo/logo-jjb.png" alt="DJBank" class="footer-logo">
<div class="footer-links">
<a th:href="@{/agreements/terms}" class="footer-link">이용약관</a>
<span class="footer-separator"></span>
<a href="https://www.jejubank.co.kr/hmpg/csct/secuCenr/ptctPlcy/procsPlcy/ctnt.do" target="_blank"
rel="noopener noreferrer" class="footer-link footer-link--external">개인정보처리방침<svg
class="footer-link-external-icon" width="14" height="14" viewBox="0 0 16 16" fill="none"
xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
<path d="M6.5 2.5H3.2A1.2 1.2 0 0 0 2 3.7v9.1A1.2 1.2 0 0 0 3.2 14h9.1a1.2 1.2 0 0 0 1.2-1.2V9.5"
stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" />
<path d="M9.5 2h4.5v4.5M14 2 7.5 8.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"
stroke-linejoin="round" />
</svg><span class="sr-only">새 창으로 열림</span></a>
</div>
<p class="footer-copyright">Copyright &copy; 2026 JEJU Bank. All Rights Reserved.</p>
</div>
<div class="footer-right">
<div class="footer-related-sites">
<select class="related-sites-select">
<option>DJBank 관련 사이트</option>
<option>DJBank 홈페이지</option>
<option>DJBank 인터넷뱅킹</option>
<option>DJBank 모바일뱅킹</option>
</select>
</div>
<p class="footer-contact" th:text="'고객센터 ' + ${customerCenterContact}">고객센터 1588-3388</p>
</div>
<p class="footer-contact" th:text="'고객센터 ' + ${customerCenterContact}">고객센터 1588-3388</p>
</div>
</div>
</div>
</footer>
</footer>
</body>
</html>
</html>
@@ -17,37 +17,32 @@
<!--/* </a>*/-->
<!--/* <a th:href="@{/}" class="logo-text">API Portal</a>*/-->
<!--/* </div>*/-->
<div >
<div>
<div class="logo">
<a th:href="@{/}" class="logo-wrapper">
<span class="logo-text-bold">DJBank</span> <span class="logo-text-thin">API Portal</span>
<a th:href="@{/}" class="mobile-logo-link">
<img src="/img/logo/logo-djb.png" alt="DJBank" class="mobile-logo">
</a>
<a th:href="@{/}" class="mobile-logo-text" style="font-size: 22px; font-weight: 700; color: #212529; text-decoration: none; margin-left: 8px; vertical-align: middle;">API Portal</a>
</div>
</div>
</div>
<!-- 상단 GNB: DB 메뉴(menuView) 기반 렌더 — .nav-menu > li / .sub-menu 구조는 하단 JS 의존 -->
<nav class="header-center">
<ul class="nav-menu">
<li>
<a href="#" class="nav-link">서비스 소개</a>
<ul class="sub-menu">
<li><a th:href="@{/service/intro}">API 포탈 소개</a></li>
<li><a th:href="@{/service/guide}">회원가입 안내</a></li>
<li><a th:href="@{/service/oauth2-guide}">OAuth2 개발가이드</a></li>
<li><a th:href="@{/service/webhook-dev-guide}">웹훅 개발가이드</a></li>
</ul>
<li th:each="menu : ${menuView.gnb}">
<th:block th:if="${!menu.children.isEmpty()}">
<a th:href="${menu.hasPath()} ? @{${menu.path}} : '#'" class="nav-link" th:text="${menu.name}">메뉴</a>
<ul class="sub-menu">
<li th:each="child : ${menu.children}">
<a th:href="@{${child.path}}" th:target="${child.newWindow} ? '_blank' : null"
th:text="${child.name}">하위 메뉴</a>
</li>
</ul>
</th:block>
<a th:if="${menu.children.isEmpty()}" th:href="${menu.hasPath()} ? @{${menu.path}} : '#'"
th:target="${menu.newWindow} ? '_blank' : null" class="nav-link" th:text="${menu.name}">메뉴</a>
</li>
<li><a href="/apis" class="nav-link">오픈 API</a></li>
<li>
<a href="#playground" class="nav-link">고객지원</a>
<ul class="sub-menu">
<li><a th:href="@{/portalnotice}">공지사항</a></li>
<li><a th:href="@{/faq_list}">FAQ</a></li>
<li><a th:href="@{/inquiry}">Q&amp;A</a></li>
<li><a th:href="@{/partnership}">피드백/개선요청</a></li>
</ul>
</li>
<li><a th:href="@{/apistatus}" class="nav-link">API Status</a></li>
</ul>
</nav>
@@ -75,20 +70,18 @@
<span class="divider"></span>
<a th:href="@{/actionLogout.do}" class="header-link">로그아웃</a>
<span class="divider"></span>
<div class="mypage-dropdown">
<button class="header-link mypage-toggle" type="button">마이페이지</button>
<!-- 마이페이지 드롭다운: DB 메뉴(menuView.mypage) 기반, 노출 권한은 서버 필터 적용 -->
<div class="mypage-dropdown" th:if="${menuView.mypage != null}">
<button class="header-link mypage-toggle" type="button"
th:text="${menuView.mypage.name}">마이페이지</button>
<div class="mypage-dropdown-menu">
<ul class="mypage-menu-list">
<li sec:authorize="hasRole('ROLE_CORP_MANAGER')">
<a th:href="@{/users}"><i class="fas fa-users"></i>개발자 관리</a>
<li th:each="child : ${menuView.mypage.children}">
<a th:href="@{${child.path}}" th:target="${child.newWindow} ? '_blank' : null">
<i th:if="${child.icon != null}" th:class="'fas ' + ${child.icon}"></i>
<th:block th:text="${child.name}">메뉴</th:block>
</a>
</li>
<li sec:authorize="hasRole('ROLE_APP')">
<a th:href="@{/clients}"><i class="fas fa-key"></i>API 신청 관리</a>
<a th:href="@{/webhook}" sec:authorize="hasRole('ROLE_WEBHOOK')"><i class="fas fa-bell"></i>Webhook 관리</a>
<a th:href="@{/statistics/api}"><i class="fas fa-chart-bar"></i>이용 통계</a>
</li>
<li><a th:href="@{/mypage}"><i class="fas fa-user-circle"></i>내 정보 관리</a></li>
<li><a th:href="@{/password/change}"><i class="fas fa-lock"></i>비밀번호 변경</a></li>
</ul>
</div>
</div>
@@ -183,72 +176,43 @@
</div>
</div>
<!-- Navigation Menu (Accordion Style) -->
<!-- Navigation Menu (Accordion Style): DB 메뉴(menuView) 기반 렌더 -->
<!-- .drawer-menu-item / .drawer-menu-btn / .drawer-submenu 구조는 하단 JS 의존 -->
<nav class="drawer-nav">
<ul class="drawer-menu-list">
<!-- 서비스 소개 -->
<li class="drawer-menu-item has-submenu">
<button class="drawer-menu-btn" type="button">
<span>서비스 소개</span>
<svg class="accordion-icon" width="14" height="7" viewBox="0 0 14 7" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 1L7 6L13 1" stroke="#212529" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<ul class="drawer-submenu">
<li><a th:href="@{/service/intro}">API 포탈 소개</a></li>
<li><a th:href="@{/service/guide}">회원가입 안내</a></li>
<li><a th:href="@{/service/oauth2-guide}">OAuth2 개발가이드</a></li>
<li><a th:href="@{/service/webhook-dev-guide}">웹훅 개발가이드</a></li>
</ul>
<li th:each="menu : ${menuView.gnb}" class="drawer-menu-item"
th:classappend="${!menu.children.isEmpty()} ? 'has-submenu' : ''">
<th:block th:if="${!menu.children.isEmpty()}">
<button class="drawer-menu-btn" type="button">
<span th:text="${menu.name}">메뉴</span>
<svg class="accordion-icon" width="14" height="7" viewBox="0 0 14 7" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 1L7 6L13 1" stroke="#212529" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<ul class="drawer-submenu">
<li th:each="child : ${menu.children}">
<a th:href="@{${child.path}}" th:target="${child.newWindow} ? '_blank' : null"
th:text="${child.name}">하위 메뉴</a>
</li>
</ul>
</th:block>
<a th:if="${menu.children.isEmpty()}" th:href="${menu.hasPath()} ? @{${menu.path}} : '#'"
th:target="${menu.newWindow} ? '_blank' : null" class="drawer-menu-btn" th:text="${menu.name}">메뉴</a>
</li>
<!-- API -->
<li class="drawer-menu-item has-submenu">
<!-- 마이페이지 (노출 권한 서버 필터 — 미노출 시 null) -->
<li class="drawer-menu-item has-submenu" th:if="${menuView.mypage != null}">
<button class="drawer-menu-btn" type="button">
<span>API</span>
<span th:text="${menuView.mypage.name}">마이페이지</span>
<svg class="accordion-icon" width="14" height="7" viewBox="0 0 14 7" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 1L7 6L13 1" stroke="#212529" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<ul class="drawer-submenu">
<li><a th:href="@{/apis}">API 마켓</a></li>
</ul>
</li>
<!-- 고객지원 -->
<li class="drawer-menu-item has-submenu">
<button class="drawer-menu-btn" type="button">
<span>고객지원</span>
<svg class="accordion-icon" width="14" height="7" viewBox="0 0 14 7" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 1L7 6L13 1" stroke="#212529" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<ul class="drawer-submenu">
<li><a th:href="@{/portalnotice}">공지사항</a></li>
<li><a th:href="@{/faq_list}">FAQ</a></li>
<li><a th:href="@{/inquiry}">Q&amp;A</a></li>
<li><a th:href="@{/partnership}">사업 제휴 문의</a></li>
</ul>
</li>
<!-- API Status -->
<li class="drawer-menu-item">
<a th:href="@{/apistatus}" class="drawer-menu-btn">API Status</a>
</li>
<!-- 마이페이지 (Authenticated Only) -->
<li class="drawer-menu-item has-submenu" sec:authorize="isAuthenticated()">
<button class="drawer-menu-btn" type="button">
<span>마이페이지</span>
<svg class="accordion-icon" width="14" height="7" viewBox="0 0 14 7" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 1L7 6L13 1" stroke="#212529" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<ul class="drawer-submenu">
<li sec:authorize="hasRole('ROLE_CORP_MANAGER')"><a th:href="@{/users}">개발자 관리</a></li>
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/clients}">API 신청 관리</a></li>
<li sec:authorize="hasRole('ROLE_WEBHOOK')"><a th:href="@{/webhook}">Webhook 관리</a></li>
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/statistics/api}">이용 통계</a></li>
<li><a th:href="@{/mypage}">내 정보 관리</a></li>
<li><a th:href="@{/password/change}">비밀번호 변경</a></li>
<li th:each="child : ${menuView.mypage.children}">
<a th:href="@{${child.path}}" th:target="${child.newWindow} ? '_blank' : null"
th:text="${child.name}">하위 메뉴</a>
</li>
</ul>
</li>
</ul>
@@ -2,41 +2,13 @@
<html xmlns:th="http://www.thymeleaf.org">
<body>
<!-- Service Left Sidebar Fragment -->
<aside class="service-sidebar" th:fragment="sidebar(activeMenu)">
<nav class="service-nav">
<!-- 서비스 소개 그룹 (Service) -->
<th:block
th:if="${activeMenu == 'intro' or activeMenu == 'guide' or activeMenu == 'oauth2' or activeMenu == 'webhookGuide'}">
<a th:href="@{/service/intro}" th:classappend="${activeMenu == 'intro'} ? 'service-nav__item--active' : ''"
class="service-nav__item">API 포탈 소개</a>
<a th:href="@{/service/guide}" th:classappend="${activeMenu == 'guide'} ? 'service-nav__item--active' : ''"
class="service-nav__item">회원가입 안내</a>
<a th:href="@{/service/oauth2-guide}"
th:classappend="${activeMenu == 'oauth2'} ? 'service-nav__item--active' : ''"
class="service-nav__item">OAuth2 개발가이드</a>
<a th:href="@{/service/webhook-dev-guide}"
th:classappend="${activeMenu == 'webhookGuide'} ? 'service-nav__item--active' : ''"
class="service-nav__item">웹훅 개발가이드</a>
</th:block>
<!-- 고객지원 그룹 (Customer Support) -->
<th:block
th:if="${activeMenu == 'notice' or activeMenu == 'faq' or activeMenu == 'qna' or activeMenu == 'feedback'}">
<a th:href="@{/portalnotice}" th:classappend="${activeMenu == 'notice'} ? 'service-nav__item--active' : ''"
class="service-nav__item">공지사항</a>
<a th:href="@{/faq_list}" th:classappend="${activeMenu == 'faq'} ? 'service-nav__item--active' : ''"
class="service-nav__item">FAQ</a>
<a th:href="@{/inquiry}" th:classappend="${activeMenu == 'qna'} ? 'service-nav__item--active' : ''"
class="service-nav__item">Q&A</a>
<a th:href="@{/partnership}" th:classappend="${activeMenu == 'feedback'} ? 'service-nav__item--active' : ''"
class="service-nav__item">피드백/개선요청</a>
</th:block>
<!-- 마이페이지 그룹 (My Page) -->
<th:block
th:if="${activeMenu == 'users' or activeMenu == 'apiKey' or activeMenu == 'statistics' or activeMenu == 'webhook' or activeMenu == 'profile' or activeMenu == 'password'}">
<!-- Profile Section -->
<!-- Service Left Sidebar Fragment: DB 메뉴(menuView) 기반 렌더.
activeMenu 파라미터는 기존 호출부 호환용 레거시 키(MenuView.LEGACY_ACTIVE_KEYS 브리지). -->
<aside class="service-sidebar" th:fragment="sidebar(activeMenu)"
th:with="grp=${menuView.activeGroup(activeMenu)}">
<nav class="service-nav" th:if="${grp != null}">
<!-- 마이페이지 그룹 프로필 -->
<th:block th:if="${grp.section == 'MYPAGE'}">
<div class="service-sidebar__profile">
<div class="avatar">
<svg viewBox="0 0 24 24" fill="currentColor">
@@ -48,29 +20,14 @@
</div>
<div class="service-sidebar__divider"></div>
<a th:href="@{/users}" th:classappend="${activeMenu == 'users'} ? 'service-nav__item--active' : ''"
class="service-nav__item" sec:authorize="hasRole('ROLE_CORP_MANAGER')">개발자 관리</a>
<a th:href="@{/clients}" th:classappend="${activeMenu == 'apiKey'} ? 'service-nav__item--active' : ''"
class="service-nav__item">API 신청 관리</a>
<a th:href="@{/webhook}" th:classappend="${activeMenu == 'webhook'} ? 'service-nav__item--active' : ''"
class="service-nav__item" sec:authorize="hasRole('ROLE_WEBHOOK')">Webhook 관리</a>
<a th:href="@{/statistics/api}"
th:classappend="${activeMenu == 'statistics'} ? 'service-nav__item--active' : ''"
class="service-nav__item" sec:authorize="hasRole('ROLE_APP')">이용통계</a>
<a th:href="@{/mypage}" th:classappend="${activeMenu == 'profile'} ? 'service-nav__item--active' : ''"
class="service-nav__item">내정보 관리</a>
<a th:href="@{/password/change}"
th:classappend="${activeMenu == 'password'} ? 'service-nav__item--active' : ''"
class="service-nav__item">비밀번호 변경</a>
</th:block>
<a th:each="child : ${grp.children}" th:href="@{${child.path}}"
th:target="${child.newWindow} ? '_blank' : null"
th:classappend="${menuView.isActive(child, activeMenu)} ? 'service-nav__item--active' : ''"
class="service-nav__item" th:text="${child.name}">메뉴</a>
</nav>
</aside>
</body>
</html>
</html>