세션 유지 기능 추가 및 FAQ 관리 개선:
- 비운영 환경 + DB 설정 활성 시 세션 유지(타임아웃 무시) 지원 - FAQ 단건 조회 API 추가 (`FaqFacade`, `FaqFacadeImpl`) - 헤더 세션 타이머 UI 개선 및 유지 옵션 체크박스 추가
This commit is contained in:
+13
@@ -13,6 +13,7 @@ import org.springframework.ui.Model;
|
|||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
|
||||||
@Controller
|
@Controller
|
||||||
public class FaqController {
|
public class FaqController {
|
||||||
@@ -47,4 +48,16 @@ public class FaqController {
|
|||||||
|
|
||||||
return "apps/community/mainFaqList";
|
return "apps/community/mainFaqList";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/faq_view")
|
||||||
|
public String view(@RequestParam(value = "id", required = false) String id, Model model) {
|
||||||
|
if (id == null) {
|
||||||
|
return "redirect:/faq_list";
|
||||||
|
}
|
||||||
|
|
||||||
|
FaqDTO faq = faqFacade.getFaq(id);
|
||||||
|
model.addAttribute("faq", faq);
|
||||||
|
|
||||||
|
return "apps/community/mainFaqDetail";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,6 @@ import org.springframework.data.domain.Pageable;
|
|||||||
|
|
||||||
public interface FaqFacade {
|
public interface FaqFacade {
|
||||||
Page<FaqDTO> getFaqs(FaqSearch search, Pageable pageable);
|
Page<FaqDTO> getFaqs(FaqSearch search, Pageable pageable);
|
||||||
|
|
||||||
|
FaqDTO getFaq(String id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,4 +34,9 @@ public class FaqFacadeImpl implements FaqFacade {
|
|||||||
return faqPage.map(faqMapper::map);
|
return faqPage.map(faqMapper::map);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public FaqDTO getFaq(String id) {
|
||||||
|
return faqMapper.map(faqService.findById(id));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ public class UserSessionService {
|
|||||||
private static final String PROPERTY_NAME = "session.timeout.minutes";
|
private static final String PROPERTY_NAME = "session.timeout.minutes";
|
||||||
private static final String DEFAULT_TIMEOUT_MINUTES = "15";
|
private static final String DEFAULT_TIMEOUT_MINUTES = "15";
|
||||||
|
|
||||||
|
/** 세션 유지(타임아웃 무시) 기능 활성화 여부 프로퍼티 (true/false). 비운영 전용 — prod 가드는 상위(GlobalControllerAdvice)에서 적용 */
|
||||||
|
private static final String KEEPALIVE_PROPERTY_NAME = "session.keepalive.enabled";
|
||||||
|
private static final String DEFAULT_KEEPALIVE_ENABLED = "false";
|
||||||
|
|
||||||
/** 논리적 만료(lastAccessTime + 타임아웃) 이후 물리적 삭제까지 두는 안전 마진(분) */
|
/** 논리적 만료(lastAccessTime + 타임아웃) 이후 물리적 삭제까지 두는 안전 마진(분) */
|
||||||
private static final long CLEANUP_MARGIN_MINUTES = 1;
|
private static final long CLEANUP_MARGIN_MINUTES = 1;
|
||||||
|
|
||||||
@@ -133,6 +137,20 @@ public class UserSessionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DB(PortalProperty)에서 세션 유지(타임아웃 무시) 기능 활성화 여부 조회.
|
||||||
|
* ⚠️ prod 가드는 포함하지 않는다 — prod 차단은 호출부(GlobalControllerAdvice)에서 {@code !prod && isKeepAliveEnabled()}로 처리.
|
||||||
|
*/
|
||||||
|
public boolean isKeepAliveEnabled() {
|
||||||
|
String value = portalPropertyService.getOrCreateProperty(
|
||||||
|
PROPERTY_GROUP,
|
||||||
|
KEEPALIVE_PROPERTY_NAME,
|
||||||
|
DEFAULT_KEEPALIVE_ENABLED,
|
||||||
|
"세션 유지(타임아웃 무시) 기능 활성화 여부 (true/false, 비운영 전용)"
|
||||||
|
);
|
||||||
|
return value != null && Boolean.parseBoolean(value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 세션의 잔여 시간(초) 계산. 세션 레코드가 없으면 0.
|
* 세션의 잔여 시간(초) 계산. 세션 레코드가 없으면 0.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import javax.servlet.http.HttpServletRequest;
|
import javax.servlet.http.HttpServletRequest;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.core.env.Environment;
|
||||||
|
import org.springframework.core.env.Profiles;
|
||||||
import org.springframework.web.bind.annotation.ControllerAdvice;
|
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||||
import com.eactive.apim.portal.config.PortalProperties;
|
import com.eactive.apim.portal.config.PortalProperties;
|
||||||
@@ -25,6 +27,9 @@ public class GlobalControllerAdvice {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private ClientGuardService clientGuardService;
|
private ClientGuardService clientGuardService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private Environment environment;
|
||||||
|
|
||||||
@ModelAttribute("breadcrumb")
|
@ModelAttribute("breadcrumb")
|
||||||
public List<Map> addBreadcrumbToModel(HttpServletRequest request) {
|
public List<Map> addBreadcrumbToModel(HttpServletRequest request) {
|
||||||
String currentPath = request.getRequestURI();
|
String currentPath = request.getRequestURI();
|
||||||
@@ -60,6 +65,17 @@ public class GlobalControllerAdvice {
|
|||||||
return userSessionService.getSessionTimeoutMinutes();
|
return userSessionService.getSessionTimeoutMinutes();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세션 유지(타임아웃 무시) 기능 사용 가능 여부.
|
||||||
|
* 비운영(!prod) + DB property(Portal/session.keepalive.enabled=Y)일 때만 true.
|
||||||
|
* prod 환경에서는 property 값과 무관하게 항상 false → UI 체크박스 미렌더.
|
||||||
|
*/
|
||||||
|
@ModelAttribute("sessionKeepAliveAllowed")
|
||||||
|
public boolean sessionKeepAliveAllowed() {
|
||||||
|
boolean prod = environment.acceptsProfiles(Profiles.of("prod"));
|
||||||
|
return !prod && userSessionService.isKeepAliveEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 클라이언트 가드 - 우클릭(contextmenu) 차단 활성화 여부.
|
* 클라이언트 가드 - 우클릭(contextmenu) 차단 활성화 여부.
|
||||||
* PortalProperty(Portal/djb.client-guard.contextmenu.enabled)에서 조회.
|
* PortalProperty(Portal/djb.client-guard.contextmenu.enabled)에서 조회.
|
||||||
|
|||||||
@@ -122,6 +122,10 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
|
|||||||
userSessionService.registerSession(sessionId, String.valueOf(user.getId()), normalizedUsername,
|
userSessionService.registerSession(sessionId, String.valueOf(user.getId()), normalizedUsername,
|
||||||
clientIp, request.getHeader("User-Agent"));
|
clientIp, request.getHeader("User-Agent"));
|
||||||
|
|
||||||
|
// 물리 세션 타임아웃을 DB property(Portal/session.timeout.minutes)와 일치시킴.
|
||||||
|
// yml/weblogic.xml 기본값을 이 세션에 대해 override → 물리=논리 단일화(CSRF 수명 포함).
|
||||||
|
session.setMaxInactiveInterval(userSessionService.getSessionTimeoutMinutes() * 60);
|
||||||
|
|
||||||
// 로그인 성공 시 세션 정보 로깅
|
// 로그인 성공 시 세션 정보 로깅
|
||||||
logLoginSuccess(request, session, username);
|
logLoginSuccess(request, session, username);
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ server:
|
|||||||
servlet:
|
servlet:
|
||||||
context-path: /
|
context-path: /
|
||||||
session:
|
session:
|
||||||
timeout: 10m
|
# 물리 세션 타임아웃은 DB PortalProperty(Portal/session.timeout.minutes)로 관리한다.
|
||||||
|
# 로그인 성공 시 PortalAuthenticationSuccessHandler 가
|
||||||
|
# session.setMaxInactiveInterval(session.timeout.minutes * 60) 으로 적용 → 물리=논리 일치.
|
||||||
|
# 익명/로그인 전 세션은 컨테이너 기본값으로 fallback (weblogic.xml <timeout-secs>1800).
|
||||||
|
# timeout: 10m
|
||||||
cookie:
|
cookie:
|
||||||
name: JSESSIONID_PORTAL
|
name: JSESSIONID_PORTAL
|
||||||
encoding:
|
encoding:
|
||||||
@@ -314,6 +318,9 @@ page:
|
|||||||
faq_list:
|
faq_list:
|
||||||
name: FAQ
|
name: FAQ
|
||||||
path: "/faq_list"
|
path: "/faq_list"
|
||||||
|
faq_detail:
|
||||||
|
name: FAQ
|
||||||
|
path: "/faq_view"
|
||||||
inquiry:
|
inquiry:
|
||||||
name: "Q&A"
|
name: "Q&A"
|
||||||
path: "/inquiry"
|
path: "/inquiry"
|
||||||
|
|||||||
@@ -5264,14 +5264,44 @@ select.form-control {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-timer-mobile {
|
.session-float {
|
||||||
margin-right: 8px;
|
position: fixed;
|
||||||
font-size: 12px;
|
top: 16px;
|
||||||
|
right: 20px;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
|
||||||
}
|
}
|
||||||
.session-timer-mobile .session-timer-text {
|
.session-float .session-keepalive {
|
||||||
min-width: 34px;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 8px;
|
||||||
|
border-left: 1px solid #e0e0e0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748B;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.session-float .session-keepalive input {
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.session-float {
|
||||||
|
top: 12px;
|
||||||
|
right: 72px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
.data-table {
|
.data-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: #FFFFFF;
|
background: #FFFFFF;
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -79,7 +79,9 @@
|
|||||||
li.innerHTML = ''
|
li.innerHTML = ''
|
||||||
+ '<div class="djb-comment-meta">'
|
+ '<div class="djb-comment-meta">'
|
||||||
+ ' <div class="djb-comment-meta-left">'
|
+ ' <div class="djb-comment-meta-left">'
|
||||||
+ ' <span class="djb-comment-writer">' + escapeHtml(c.writerName || '(알 수 없음)') + '</span>'
|
+ (c.adminYn === 'Y'
|
||||||
|
? ' <span class="djb-comment-admin-badge">관리자</span>'
|
||||||
|
: ' <span class="djb-comment-writer">' + escapeHtml(c.writerName || '(알 수 없음)') + '</span>')
|
||||||
+ ' </div>'
|
+ ' </div>'
|
||||||
+ ' <div class="djb-comment-meta-right">'
|
+ ' <div class="djb-comment-meta-right">'
|
||||||
+ ' <span class="djb-comment-date">' + escapeHtml(formatDate(c.createdDate)) + '</span>'
|
+ ' <span class="djb-comment-date">' + escapeHtml(formatDate(c.createdDate)) + '</span>'
|
||||||
|
|||||||
@@ -32,12 +32,51 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 모바일 헤더(햄버거 버튼 좌측)용 타이머
|
// -----------------------------------------------------------------------------
|
||||||
.session-timer-mobile {
|
// 우측 상단 Float 세션 타이머 위젯
|
||||||
margin-right: $spacing-sm;
|
// 데스크톱: 헤더 우측(유저 메뉴 우측)에 상단 정렬로 고정(fixed).
|
||||||
font-size: $font-size-xs;
|
// 모바일: 헤더 우측 햄버거(메뉴 버튼) 바로 왼쪽에 배치.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
.session-float {
|
||||||
|
position: fixed;
|
||||||
|
top: 16px;
|
||||||
|
right: 20px;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
padding: 6px 14px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
|
||||||
|
|
||||||
.session-timer-text {
|
// 세션 유지 체크박스 (비운영 전용, 조건부 렌더)
|
||||||
min-width: 34px;
|
.session-keepalive {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 8px;
|
||||||
|
border-left: 1px solid #e0e0e0;
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
color: $text-gray;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
input {
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모바일: 헤더 우측 햄버거(메뉴 버튼) 바로 왼쪽에 나란히 배치.
|
||||||
|
// right = 헤더 padding(20px) + 햄버거 폭(~32px) + 간격(~20px) ≈ 72px
|
||||||
|
@media (max-width: $breakpoint-sm) {
|
||||||
|
.session-float {
|
||||||
|
top: 12px;
|
||||||
|
right: 72px;
|
||||||
|
padding: 4px 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<th:block layout:fragment="contentFragment">
|
<th:block layout:fragment="contentFragment">
|
||||||
<section class="user-management-container">
|
<section class="user-management-container notice-list-container">
|
||||||
|
|
||||||
<!-- Search and Filter Controls -->
|
<!-- Search and Filter Controls -->
|
||||||
<form name="faqForm" th:action="@{/faq_list}" method="post" th:object="${search}" onsubmit="return false;">
|
<form name="faqForm" th:action="@{/faq_list}" method="post" th:object="${search}" onsubmit="return false;">
|
||||||
@@ -38,21 +38,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- FAQ Accordion -->
|
<!-- FAQ Table -->
|
||||||
<div class="faq-accordion" th:if="${!#lists.isEmpty(page.content)}">
|
<div class="list-table" th:if="${!#lists.isEmpty(page.content)}">
|
||||||
<div class="faq-item" th:each="faq : ${page.content}">
|
<!-- Table Header -->
|
||||||
<div class="faq-question">
|
<div class="list-table-header">
|
||||||
<span class="faq-question-text" th:text="${faq.faqQuestion}">질문 내용이 여기에 표시됩니다.</span>
|
<div class="header-cell" style="width: 80px;">NO</div>
|
||||||
<span class="faq-icon">
|
<div class="header-cell" style="flex: 1; min-width: 200px;">질문</div>
|
||||||
<svg width="14" height="8" viewBox="0 0 14 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<div class="header-cell" style="width: 120px;">등록일</div>
|
||||||
<path d="M1 1L7 7L13 1" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
</div>
|
||||||
|
|
||||||
|
<!-- Table Body -->
|
||||||
|
<div class="list-table-body">
|
||||||
|
<div class="list-table-row"
|
||||||
|
th:each="faq, status : ${page.content}"
|
||||||
|
th:onclick="'location.href=\'' + @{/faq_view(id=${faq.id})} + '\''"
|
||||||
|
style="cursor: pointer;">
|
||||||
|
<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" style="flex: 1; min-width: 200px;" data-label="질문">
|
||||||
|
<a th:href="@{/faq_view(id=${faq.id})}" class="notice-title-link">
|
||||||
|
<span class="notice-number" th:text="|[${page.totalElements - (page.number * page.size) - status.index}]|">[1]</span>
|
||||||
|
<span th:text="${faq.faqQuestion}">FAQ 질문</span>
|
||||||
|
<span class="file-icon" th:if="${faq.fileId != null and !faq.fileId.isEmpty()}">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 15.75C18 17.4833 17.3917 18.9583 16.175 20.175C14.9583 21.3917 13.4833 22 11.75 22C10.0167 22 8.54167 21.3917 7.325 20.175C6.10833 18.9583 5.5 17.4833 5.5 15.75V6.5C5.5 5.25 5.9375 4.1875 6.8125 3.3125C7.6875 2.4375 8.75 2 10 2C11.25 2 12.3125 2.4375 13.1875 3.3125C14.0625 4.1875 14.5 5.25 14.5 6.5V15.25C14.5 16.0167 14.2333 16.6667 13.7 17.2C13.1667 17.7333 12.5167 18 11.75 18C10.9833 18 10.3333 17.7333 9.8 17.2C9.26667 16.6667 9 16.0167 9 15.25V6H11V15.25C11 15.4667 11.0708 15.6458 11.2125 15.7875C11.3542 15.9292 11.5333 16 11.75 16C11.9667 16 12.1458 15.9292 12.2875 15.7875C12.4292 15.6458 12.5 15.4667 12.5 15.25V6.5C12.4833 5.8 12.2375 5.20833 11.7625 4.725C11.2875 4.24167 10.7 4 10 4C9.3 4 8.70833 4.24167 8.225 4.725C7.74167 5.20833 7.5 5.8 7.5 6.5V15.75C7.48333 16.9333 7.89167 17.9375 8.725 18.7625C9.55833 19.5875 10.5667 20 11.75 20C12.9167 20 13.9083 19.5875 14.725 18.7625C15.5417 17.9375 15.9667 16.9333 16 15.75V6H18V15.75Z" fill="currentColor"/>
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="faq-answer">
|
<div class="row-cell" style="width: 120px;" data-label="등록일"
|
||||||
<div class="faq-answer-content editor-content" th:utext="${faq.faqAnswer}">
|
th:text="${#temporals.format(faq.createdDate, 'yyyy.MM.dd')}">2025.01.01</div>
|
||||||
답변 내용이 여기에 표시됩니다.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,37 +110,6 @@
|
|||||||
document.faqForm.page.value = 1;
|
document.faqForm.page.value = 1;
|
||||||
document.faqForm.submit();
|
document.faqForm.submit();
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeHTMLEntities(text) {
|
|
||||||
var textArea = document.createElement('textarea');
|
|
||||||
textArea.innerHTML = text;
|
|
||||||
return textArea.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
// Decode HTML entities in FAQ answers
|
|
||||||
document.querySelectorAll('.faq-answer-content').forEach(function(element) {
|
|
||||||
element.innerHTML = decodeHTMLEntities(element.innerHTML);
|
|
||||||
});
|
|
||||||
|
|
||||||
// FAQ Accordion functionality
|
|
||||||
document.querySelectorAll('.faq-question').forEach(function(question) {
|
|
||||||
question.addEventListener('click', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const faqItem = this.closest('.faq-item');
|
|
||||||
const isActive = faqItem.classList.contains('active');
|
|
||||||
|
|
||||||
// Close all other items (optional - for single open behavior)
|
|
||||||
// document.querySelectorAll('.faq-item.active').forEach(function(item) {
|
|
||||||
// item.classList.remove('active');
|
|
||||||
// });
|
|
||||||
|
|
||||||
// Toggle active state
|
|
||||||
faqItem.classList.toggle('active');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|
||||||
|
|||||||
@@ -157,6 +157,27 @@
|
|||||||
|
|
||||||
$(function () {
|
$(function () {
|
||||||
|
|
||||||
|
// CSRF 토큰은 세션 기반 → 세션 만료(server.servlet.session.timeout=10m) 전에
|
||||||
|
// 메인 페이지로 이동시켜 stale 토큰 제출(403) 방지. 이동은 서버 요청이라 세션 idle도 리셋됨.
|
||||||
|
// 단, 입력 중에는 이탈 금지 → idle 타이머(활동 시 리셋, 입력값/포커스 있으면 보류).
|
||||||
|
// 만료 시간은 하드코딩하지 않고 서버 세션 타임아웃(초)을 렌더 시점에 읽어 파생 → yml 변경 시 자동 반영.
|
||||||
|
var SESSION_TIMEOUT_SEC = /*[[${#request.session.maxInactiveInterval}]]*/ 600;
|
||||||
|
var LOGIN_IDLE_LIMIT_MS = Math.max(60, SESSION_TIMEOUT_SEC - 120) * 1000; // 세션 만료 2분 전
|
||||||
|
var loginIdleTimer;
|
||||||
|
function scheduleIdleRedirect() {
|
||||||
|
clearTimeout(loginIdleTimer);
|
||||||
|
loginIdleTimer = setTimeout(function () {
|
||||||
|
var idEl = document.getElementById('id');
|
||||||
|
var pwEl = document.getElementById('password');
|
||||||
|
var busy = document.activeElement === idEl || document.activeElement === pwEl
|
||||||
|
|| (idEl && idEl.value) || (pwEl && pwEl.value);
|
||||||
|
if (busy) { scheduleIdleRedirect(); return; } // 입력 중/입력값 있음 → 이탈 보류
|
||||||
|
window.location.href = /*[[@{/}]]*/ '/';
|
||||||
|
}, LOGIN_IDLE_LIMIT_MS);
|
||||||
|
}
|
||||||
|
$('#id, #password, #checkId').on('input keydown focus click', scheduleIdleRedirect);
|
||||||
|
scheduleIdleRedirect();
|
||||||
|
|
||||||
var successMsg = [[${success}]];
|
var successMsg = [[${success}]];
|
||||||
console.log("Success message:", successMsg);
|
console.log("Success message:", successMsg);
|
||||||
if (successMsg) {
|
if (successMsg) {
|
||||||
|
|||||||
@@ -54,11 +54,6 @@
|
|||||||
<!-- Logout State (Authenticated) -->
|
<!-- Logout State (Authenticated) -->
|
||||||
<div class="auth-group authenticated" sec:authorize="isAuthenticated()">
|
<div class="auth-group authenticated" sec:authorize="isAuthenticated()">
|
||||||
<div class="header-user-info">
|
<div class="header-user-info">
|
||||||
<span class="session-timer" id="sessionTimer" title="남은 세션 시간">
|
|
||||||
<i class="fas fa-clock session-timer-icon"></i>
|
|
||||||
<span class="session-timer-text" id="sessionRemainingTime">--:--</span>
|
|
||||||
</span>
|
|
||||||
<span class="divider">•</span>
|
|
||||||
<div class="user-identity">
|
<div class="user-identity">
|
||||||
<img th:src="@{/img/user_icon.svg}" alt="User" class="user-icon">
|
<img th:src="@{/img/user_icon.svg}" alt="User" class="user-icon">
|
||||||
<span class="user-name">[[${#authentication.principal.userName}]]님</span>
|
<span class="user-name">[[${#authentication.principal.userName}]]님</span>
|
||||||
@@ -97,10 +92,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mobile-right">
|
<div class="mobile-right">
|
||||||
<span class="session-timer session-timer-mobile" sec:authorize="isAuthenticated()" title="남은 세션 시간">
|
|
||||||
<i class="fas fa-clock session-timer-icon"></i>
|
|
||||||
<span class="session-timer-text">--:--</span>
|
|
||||||
</span>
|
|
||||||
<button class="mobile-menu-btn" id="mobileMenuToggle" aria-label="메뉴">
|
<button class="mobile-menu-btn" id="mobileMenuToggle" aria-label="메뉴">
|
||||||
<span class="hamburger-line"></span>
|
<span class="hamburger-line"></span>
|
||||||
<span class="hamburger-line"></span>
|
<span class="hamburger-line"></span>
|
||||||
@@ -113,6 +104,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- 우측 상단 Float 세션 타이머 (인증 사용자). 모바일에선 헤더/햄버거 아래로 내려 겹침 방지 -->
|
||||||
|
<div class="session-float" sec:authorize="isAuthenticated()">
|
||||||
|
<span class="session-timer" id="sessionTimer" title="남은 세션 시간">
|
||||||
|
<i class="fas fa-clock session-timer-icon"></i>
|
||||||
|
<span class="session-timer-text" id="sessionRemainingTime">--:--</span>
|
||||||
|
</span>
|
||||||
|
<!-- 세션 유지(타임아웃 무시) 체크박스: 비운영 + property 활성 시에만 렌더 (prod 미노출) -->
|
||||||
|
<label class="session-keepalive" th:if="${sessionKeepAliveAllowed}" title="세션 자동 유지 (비운영 전용)">
|
||||||
|
<input type="checkbox" id="sessionKeepAliveToggle">
|
||||||
|
<span>세션 유지</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Mobile Drawer/Modal -->
|
<!-- Mobile Drawer/Modal -->
|
||||||
<div class="mobile-drawer" id="mobileDrawer">
|
<div class="mobile-drawer" id="mobileDrawer">
|
||||||
<div class="drawer-overlay" id="drawerOverlay"></div>
|
<div class="drawer-overlay" id="drawerOverlay"></div>
|
||||||
@@ -260,6 +264,7 @@
|
|||||||
var countdownTimer = null;
|
var countdownTimer = null;
|
||||||
var promptShown = false; // 연장 확인 모달 중복 표시 방지
|
var promptShown = false; // 연장 확인 모달 중복 표시 방지
|
||||||
var redirecting = false;
|
var redirecting = false;
|
||||||
|
var keepAliveActive = false; // 세션 유지 체크 시 true → 카운트다운 정지, 무제한(∞) 표시
|
||||||
|
|
||||||
function getCookie(name) {
|
function getCookie(name) {
|
||||||
var m = document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)');
|
var m = document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)');
|
||||||
@@ -287,6 +292,14 @@
|
|||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
if (!timerTexts.length) return;
|
if (!timerTexts.length) return;
|
||||||
|
// 세션 유지 활성 시 카운트다운 대신 무제한(∞) 표시
|
||||||
|
if (keepAliveActive) {
|
||||||
|
for (var k = 0; k < timerTexts.length; k++) {
|
||||||
|
timerTexts[k].textContent = '∞';
|
||||||
|
timerTexts[k].classList.remove('session-timer-warning');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
var label = formatTime(remainingSeconds);
|
var label = formatTime(remainingSeconds);
|
||||||
var warn = remainingSeconds <= WARNING_SECONDS;
|
var warn = remainingSeconds <= WARNING_SECONDS;
|
||||||
for (var i = 0; i < timerTexts.length; i++) {
|
for (var i = 0; i < timerTexts.length; i++) {
|
||||||
@@ -350,6 +363,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function tick() {
|
function tick() {
|
||||||
|
// 세션 유지 활성 시 로컬 카운트다운·만료 처리 중단 (heartbeat가 세션 연장)
|
||||||
|
if (keepAliveActive) {
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
remainingSeconds--;
|
remainingSeconds--;
|
||||||
if (remainingSeconds <= 0) {
|
if (remainingSeconds <= 0) {
|
||||||
goTo(EXPIRED_URL);
|
goTo(EXPIRED_URL);
|
||||||
@@ -387,6 +405,44 @@
|
|||||||
render();
|
render();
|
||||||
countdownTimer = setInterval(tick, 1000);
|
countdownTimer = setInterval(tick, 1000);
|
||||||
setInterval(pollStatus, POLL_INTERVAL_MS);
|
setInterval(pollStatus, POLL_INTERVAL_MS);
|
||||||
|
|
||||||
|
// 세션 유지(타임아웃 무시): 비운영 + DB property 활성 시에만 동작(prod는 서버가 false 주입 → 체크박스도 미렌더).
|
||||||
|
// 체크 시 주기적 heartbeat로 세션을 계속 연장. 탭을 닫으면 heartbeat 중단 → 정상 만료.
|
||||||
|
var KEEPALIVE_ALLOWED = /*[[${sessionKeepAliveAllowed}]]*/ false;
|
||||||
|
var KEEPALIVE_LS_KEY = 'sessionKeepAlive';
|
||||||
|
var KEEPALIVE_INTERVAL_MS = 60000; // 세션 타임아웃보다 충분히 짧게
|
||||||
|
var keepAliveTimer = null;
|
||||||
|
var keepAliveToggle = document.getElementById('sessionKeepAliveToggle');
|
||||||
|
|
||||||
|
function startKeepAlive() {
|
||||||
|
keepAliveActive = true;
|
||||||
|
render(); // 즉시 ∞ 표시
|
||||||
|
if (keepAliveTimer) return;
|
||||||
|
keepAliveTimer = setInterval(extendSession, KEEPALIVE_INTERVAL_MS);
|
||||||
|
extendSession(); // 즉시 1회 연장
|
||||||
|
}
|
||||||
|
function stopKeepAlive() {
|
||||||
|
keepAliveActive = false;
|
||||||
|
if (keepAliveTimer) { clearInterval(keepAliveTimer); keepAliveTimer = null; }
|
||||||
|
render(); // 카운트다운 표시 복귀
|
||||||
|
pollStatus(); // 정지 중 stale 해소: 서버 잔여시간 즉시 재동기화
|
||||||
|
}
|
||||||
|
|
||||||
|
if (KEEPALIVE_ALLOWED && keepAliveToggle) {
|
||||||
|
if (localStorage.getItem(KEEPALIVE_LS_KEY) === 'true') {
|
||||||
|
keepAliveToggle.checked = true;
|
||||||
|
startKeepAlive();
|
||||||
|
}
|
||||||
|
keepAliveToggle.addEventListener('change', function () {
|
||||||
|
if (keepAliveToggle.checked) {
|
||||||
|
localStorage.setItem(KEEPALIVE_LS_KEY, 'true');
|
||||||
|
startKeepAlive();
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(KEEPALIVE_LS_KEY);
|
||||||
|
stopKeepAlive();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user