Merge branch 'feature/session-timeout'
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled

# Conflicts:
#	src/main/resources/static/css/main.css.map
This commit is contained in:
Rinjae
2026-06-19 18:09:47 +09:00
18 changed files with 849 additions and 18 deletions
+31
View File
@@ -5310,6 +5310,37 @@ 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;
}
.session-timer-mobile {
margin-right: 8px;
font-size: 12px;
}
.session-timer-mobile .session-timer-text {
min-width: 34px;
}
.data-table {
width: 100%;
background: #FFFFFF;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,43 @@
@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;
}
}
}
// 모바일 헤더(햄버거 버튼 좌측)용 타이머
.session-timer-mobile {
margin-right: $spacing-sm;
font-size: $font-size-xs;
.session-timer-text {
min-width: 34px;
}
}
+1
View File
@@ -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">
@@ -107,6 +115,36 @@
form.checkId.checked = ((form.id.value = getCookie('saveid')) !== null);
}
// 로그인 전 중복 접속 확인 → 중복 시 기존 세션 강제 로그아웃 여부 질의
function checkDuplicateAndLogin(form) {
var loginId = form.id.value;
$.ajax({
url: /*[[@{/api/session/check-duplicate}]]*/ '/api/session/check-duplicate',
type: 'POST',
data: { loginId: loginId },
success: function (data) {
if (data && data.duplicateSession) {
$('#loginLoading').removeClass('active');
var msg = '해당 계정은 이미 다른 곳(' + data.ipAddress + ')에서 접속 중입니다.<br>'
+ '접속 시각: ' + data.loginTime + '<br><br>'
+ '기존 접속을 해제하고 로그인하시겠습니까?';
customPopups.showConfirm(msg, function (confirmed) {
if (confirmed) {
$('#loginLoading').addClass('active');
form.submit();
}
});
} else {
form.submit();
}
},
error: function () {
// 중복 체크 실패 시 로그인은 그대로 진행
form.submit();
}
});
}
function fnInit() {
let form = document.getElementById('loginForm');
if (form) {
@@ -171,7 +209,7 @@
$('#loginLoading').removeClass('active');
customPopups.showAlert('[[#{login.passLengthShort}]]');
} else {
form.submit();
checkDuplicateAndLogin(form);
}
}
form.classList.add('was-validated');
@@ -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>
@@ -102,6 +107,10 @@
</div>
<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="메뉴">
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
@@ -243,6 +252,148 @@
<!-- 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; // 서버 잔여시간 동기화 주기
// 데스크톱·모바일 타이머 텍스트를 모두 갱신 (id 대신 클래스 사용)
var timerTexts = document.querySelectorAll('.session-timer-text');
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 (!timerTexts.length) return;
var label = formatTime(remainingSeconds);
var warn = remainingSeconds <= WARNING_SECONDS;
for (var i = 0; i < timerTexts.length; i++) {
timerTexts[i].textContent = label;
if (warn) {
timerTexts[i].classList.add('session-timer-warning');
} else {
timerTexts[i].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