Merge branch 'feature/session-timeout'
# Conflicts: # src/main/resources/static/css/main.css.map
This commit is contained in:
@@ -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
+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,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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user