- 세션 타이머·유휴 자동 로그아웃 기능 추가
- 만료 직전 연장 확인 모달, PortalProperty로 시간 설정 - PTL_USER_SESSION 기반 세션 검증 필터/API 도입 - 로그인 시 중복 세션 강제 로그아웃 처리 - 세션 테이블 DDL 및 로그인 안내 메시지 추가 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5310,6 +5310,29 @@ select.form-control {
|
||||
right: -20px;
|
||||
}
|
||||
}
|
||||
.session-timer {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #64748B;
|
||||
}
|
||||
.session-timer .session-timer-icon {
|
||||
font-size: 12px;
|
||||
color: #64748B;
|
||||
}
|
||||
.session-timer .session-timer-text {
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
.session-timer .session-timer-text.session-timer-warning {
|
||||
color: #e03131;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
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
@@ -0,0 +1,33 @@
|
||||
@use '../abstracts/variables' as *;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Session Timer Component
|
||||
// 인증 사용자 헤더에 남은 세션 시간을 표시. 만료 임박(<=60s) 시 경고색.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
.session-timer {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $spacing-xs;
|
||||
white-space: nowrap;
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $text-gray;
|
||||
|
||||
.session-timer-icon {
|
||||
font-size: $font-size-xs;
|
||||
color: $text-gray;
|
||||
}
|
||||
|
||||
.session-timer-text {
|
||||
// 카운트다운 중 폭 흔들림 방지
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 40px;
|
||||
text-align: center;
|
||||
|
||||
&.session-timer-warning {
|
||||
color: #e03131;
|
||||
font-weight: $font-weight-bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@
|
||||
@use 'components/cta' as *;
|
||||
@use 'components/password-popup' as *;
|
||||
@use 'components/header-auth' as *;
|
||||
@use 'components/session-timer' as *;
|
||||
@use 'components/tables' as *;
|
||||
@use 'components/accordion' as *;
|
||||
@use 'components/page-title-banner' as *;
|
||||
|
||||
@@ -19,6 +19,14 @@
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<span>로그아웃되었습니다.</span>
|
||||
</div>
|
||||
<div th:if="${param.expired}" class="login-alert alert-info">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<span>장시간 미사용으로 세션이 만료되어 로그아웃되었습니다. 다시 로그인해주세요.</span>
|
||||
</div>
|
||||
<div th:if="${param.forceLogout}" class="login-alert alert-info">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<span>다른 기기 또는 브라우저에서 로그인되어 현재 세션이 종료되었습니다.</span>
|
||||
</div>
|
||||
|
||||
<!-- Login Form -->
|
||||
<form id="loginForm" role="form" name="loginForm" th:action="@{/actionLogin.do}" method="post" class="login-form">
|
||||
|
||||
@@ -64,6 +64,11 @@
|
||||
<!-- Logout State (Authenticated) -->
|
||||
<div class="auth-group authenticated" sec:authorize="isAuthenticated()">
|
||||
<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">
|
||||
<img th:src="@{/img/user_icon.svg}" alt="User" class="user-icon">
|
||||
<span class="user-name">[[${#authentication.principal.userName}]]님</span>
|
||||
@@ -242,6 +247,143 @@
|
||||
|
||||
<!-- Mobile Menu & Dropdown JavaScript -->
|
||||
<th:block th:fragment="headerScript">
|
||||
<!-- 세션 타이머 / 유휴 자동 로그아웃 / 연장 확인 (인증 사용자만) -->
|
||||
<script sec:authorize="isAuthenticated()" th:inline="javascript">
|
||||
(function () {
|
||||
var STATUS_URL = /*[[@{/api/session/status}]]*/ '/api/session/status';
|
||||
var HEARTBEAT_URL = /*[[@{/api/session/heartbeat}]]*/ '/api/session/heartbeat';
|
||||
var EXPIRED_URL = /*[[@{/login?expired=true}]]*/ '/login?expired=true';
|
||||
var FORCE_LOGOUT_URL = /*[[@{/login?forceLogout=true}]]*/ '/login?forceLogout=true';
|
||||
var LOGOUT_URL = /*[[@{/actionLogout.do}]]*/ '/actionLogout.do';
|
||||
|
||||
var timeoutMinutes = /*[[${sessionTimeoutMinutes}]]*/ 15;
|
||||
var remainingSeconds = timeoutMinutes * 60;
|
||||
var WARNING_SECONDS = 60; // 만료 60초 전 연장 확인 모달
|
||||
var POLL_INTERVAL_MS = 30000; // 서버 잔여시간 동기화 주기
|
||||
|
||||
var timerText = document.getElementById('sessionRemainingTime');
|
||||
var countdownTimer = null;
|
||||
var promptShown = false; // 연장 확인 모달 중복 표시 방지
|
||||
var redirecting = false;
|
||||
|
||||
function getCookie(name) {
|
||||
var m = document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)');
|
||||
return m ? decodeURIComponent(m.pop()) : '';
|
||||
}
|
||||
|
||||
function csrfHeaders() {
|
||||
var token = getCookie('XSRF-TOKEN');
|
||||
return token ? { 'X-XSRF-TOKEN': token } : {};
|
||||
}
|
||||
|
||||
function formatTime(sec) {
|
||||
if (sec < 0) sec = 0;
|
||||
var m = Math.floor(sec / 60);
|
||||
var s = sec % 60;
|
||||
return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!timerText) return;
|
||||
timerText.textContent = formatTime(remainingSeconds);
|
||||
if (remainingSeconds <= WARNING_SECONDS) {
|
||||
timerText.classList.add('session-timer-warning');
|
||||
} else {
|
||||
timerText.classList.remove('session-timer-warning');
|
||||
}
|
||||
}
|
||||
|
||||
function goTo(url) {
|
||||
if (redirecting) return;
|
||||
redirecting = true;
|
||||
if (countdownTimer) clearInterval(countdownTimer);
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
function extendSession() {
|
||||
$.ajax({
|
||||
url: HEARTBEAT_URL,
|
||||
type: 'POST',
|
||||
headers: csrfHeaders(),
|
||||
success: function (data) {
|
||||
if (data && data.remainingSeconds > 0) {
|
||||
remainingSeconds = data.remainingSeconds;
|
||||
promptShown = false;
|
||||
render();
|
||||
} else {
|
||||
goTo(EXPIRED_URL);
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
// 연장 실패 시 만료로 처리
|
||||
goTo(EXPIRED_URL);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showExtendConfirm() {
|
||||
if (typeof customPopups === 'undefined' || !customPopups.showConfirm) {
|
||||
return; // 팝업 미로딩 시 카운트다운만으로 만료 처리
|
||||
}
|
||||
var $yes = $('#customConfirmYesButton');
|
||||
var $no = $('#customConfirmNoButton');
|
||||
var prevYes = $yes.text();
|
||||
var prevNo = $no.text();
|
||||
$yes.text('연장하기');
|
||||
$no.text('로그아웃');
|
||||
customPopups.showConfirm('세션이 곧 만료됩니다. 로그인 상태를 연장하시겠습니까?', function (extend) {
|
||||
// 버튼 라벨 원복 (다른 confirm 팝업에 영향 없도록)
|
||||
$yes.text(prevYes);
|
||||
$no.text(prevNo);
|
||||
if (extend) {
|
||||
extendSession();
|
||||
} else {
|
||||
goTo(LOGOUT_URL);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function tick() {
|
||||
remainingSeconds--;
|
||||
if (remainingSeconds <= 0) {
|
||||
goTo(EXPIRED_URL);
|
||||
return;
|
||||
}
|
||||
if (remainingSeconds <= WARNING_SECONDS && !promptShown) {
|
||||
promptShown = true;
|
||||
showExtendConfirm();
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
function pollStatus() {
|
||||
$.ajax({
|
||||
url: STATUS_URL,
|
||||
type: 'GET',
|
||||
success: function (data) {
|
||||
if (!data || !data.valid) {
|
||||
goTo(FORCE_LOGOUT_URL);
|
||||
return;
|
||||
}
|
||||
remainingSeconds = data.remainingSeconds;
|
||||
if (remainingSeconds > WARNING_SECONDS) {
|
||||
promptShown = false; // 다른 탭/활동으로 연장된 경우 모달 재무장
|
||||
}
|
||||
render();
|
||||
},
|
||||
error: function () {
|
||||
// 폴링 실패 시 로컬 카운트다운 유지
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 초기화
|
||||
render();
|
||||
countdownTimer = setInterval(tick, 1000);
|
||||
setInterval(pollStatus, POLL_INTERVAL_MS);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Mobile Drawer
|
||||
|
||||
Reference in New Issue
Block a user