fix: 전 시스템 Chrome 무한 로그인 루프 해결 및 role 대소문자 통일
- gateway: 로그인 페이지 자동 리다이렉트 시 SSO 쿠키 재설정 + Cache-Control no-store - tkreport(system2): SW 해제, 401 핸들러 리다이렉트 제거, 루프 방지, localStorage 백업 - TKQC 모바일(system3): mCheckAuth를 authManager 위임으로 변경, 루프 방지 - TKQC 공통(system3): api.js 로그인 URL 캐시 버스팅, auth-manager localStorage 백업 - tkuser: SW 해제, 401 핸들러 수정, 루프 방지, localStorage 백업, requireAdmin role 소문자 통일 - system1: 작업보고서 admin role 대소문자 무시, refresh 토큰에 role 필드 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -205,6 +205,13 @@
|
||||
var existingToken = ssoCookie.get('sso_token') || localStorage.getItem('sso_token');
|
||||
if (existingToken && existingToken !== 'undefined' && existingToken !== 'null') {
|
||||
if (isTokenValid(existingToken)) {
|
||||
// 쿠키 재설정 (localStorage에만 있고 쿠키가 없는 경우 대비)
|
||||
var existingUser = ssoCookie.get('sso_user') || localStorage.getItem('sso_user');
|
||||
var existingRefresh = ssoCookie.get('sso_refresh_token') || localStorage.getItem('sso_refresh_token');
|
||||
ssoCookie.set('sso_token', existingToken, 7);
|
||||
if (existingUser) ssoCookie.set('sso_user', existingUser, 7);
|
||||
if (existingRefresh) ssoCookie.set('sso_refresh_token', existingRefresh, 30);
|
||||
|
||||
var redirect = new URLSearchParams(location.search).get('redirect');
|
||||
window.location.href = (redirect && isSafeRedirect(redirect)) ? redirect : '/';
|
||||
} else {
|
||||
|
||||
@@ -7,8 +7,10 @@ server {
|
||||
# ===== Gateway 자체 페이지 (포털, 로그인) =====
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
# 로그인 페이지
|
||||
# 로그인 페이지 (캐시 금지 — SSO 쿠키 재설정 로직 항상 최신 반영)
|
||||
location = /login {
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
try_files /login.html =404;
|
||||
}
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ const getDailyWorkReports = async (req, res) => {
|
||||
try {
|
||||
const userInfo = {
|
||||
user_id: req.user?.user_id || req.user?.id,
|
||||
role: req.user?.role || 'user'
|
||||
role: (req.user?.role || req.user?.access_level || 'user').toLowerCase()
|
||||
};
|
||||
|
||||
if (!userInfo.user_id) {
|
||||
@@ -303,7 +303,7 @@ const updateWorkReport = async (req, res) => {
|
||||
const updateData = req.body;
|
||||
const userInfo = {
|
||||
user_id: req.user?.user_id || req.user?.id,
|
||||
role: req.user?.role || 'user'
|
||||
role: (req.user?.role || req.user?.access_level || 'user').toLowerCase()
|
||||
};
|
||||
|
||||
if (!userInfo.user_id) {
|
||||
|
||||
@@ -137,9 +137,11 @@ router.post('/refresh-token', async (req, res) => {
|
||||
|
||||
const connection = await getDb();
|
||||
|
||||
// 사용자 정보 조회
|
||||
// 사용자 정보 조회 (roles 조인으로 role_name 포함)
|
||||
const [users] = await connection.execute(
|
||||
'SELECT * FROM users WHERE user_id = ? AND is_active = TRUE',
|
||||
`SELECT u.*, r.name as role_name, u._access_level_old as access_level
|
||||
FROM users u LEFT JOIN roles r ON u.role_id = r.id
|
||||
WHERE u.user_id = ? AND u.is_active = TRUE`,
|
||||
[decoded.user_id]
|
||||
);
|
||||
|
||||
@@ -149,11 +151,12 @@ router.post('/refresh-token', async (req, res) => {
|
||||
|
||||
const user = users[0];
|
||||
|
||||
// 새 토큰 발급
|
||||
// 새 토큰 발급 (role 필드 포함)
|
||||
const newToken = jwt.sign(
|
||||
{
|
||||
user_id: user.user_id,
|
||||
username: user.username,
|
||||
role: user.role_name || user.access_level || 'user',
|
||||
access_level: user.access_level,
|
||||
name: user.name || user.username
|
||||
},
|
||||
|
||||
@@ -128,8 +128,9 @@ const getDailyWorkReportsService = async (queryParams, userInfo) => {
|
||||
});
|
||||
}
|
||||
|
||||
// 관리자 여부 확인
|
||||
const isAdmin = role === 'system' || role === 'admin';
|
||||
// 관리자 여부 확인 (대소문자 무시)
|
||||
const roleLower = (role || '').toLowerCase();
|
||||
const isAdmin = roleLower === 'system' || roleLower === 'admin' || roleLower === 'system admin';
|
||||
const canViewAll = isAdmin || view_all === 'true';
|
||||
|
||||
// 모델에 전달할 조회 옵션 객체 생성
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
// /js/api-base.js
|
||||
// API 기본 설정 및 보안 유틸리티 - System 2 (신고 시스템)
|
||||
|
||||
// 서비스 워커 해제 (캐시 간섭으로 인한 인증 루프 방지)
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.getRegistrations().then(function(registrations) {
|
||||
registrations.forEach(function(registration) { registration.unregister(); });
|
||||
});
|
||||
if (typeof caches !== 'undefined') {
|
||||
caches.keys().then(function(names) {
|
||||
names.forEach(function(name) { caches.delete(name); });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
@@ -35,10 +47,11 @@
|
||||
*/
|
||||
window.getLoginUrl = function() {
|
||||
var hostname = window.location.hostname;
|
||||
var t = Date.now();
|
||||
if (hostname.includes('technicalkorea.net')) {
|
||||
return window.location.protocol + '//tkfb.technicalkorea.net/login?redirect=' + encodeURIComponent(window.location.href);
|
||||
return window.location.protocol + '//tkfb.technicalkorea.net/login?redirect=' + encodeURIComponent(window.location.href) + '&_t=' + t;
|
||||
}
|
||||
return window.location.protocol + '//' + hostname + ':30000/login?redirect=' + encodeURIComponent(window.location.href);
|
||||
return window.location.protocol + '//' + hostname + ':30000/login?redirect=' + encodeURIComponent(window.location.href) + '&_t=' + t;
|
||||
};
|
||||
|
||||
window.clearSSOAuth = function() {
|
||||
@@ -123,10 +136,9 @@
|
||||
|
||||
var response = await fetch(url, config);
|
||||
|
||||
// 401 Unauthorized 처리
|
||||
// 401 Unauthorized 처리 — 토큰만 정리하고 에러 throw (리다이렉트는 app-init이 처리)
|
||||
if (response.status === 401) {
|
||||
window.clearSSOAuth();
|
||||
window.location.href = window.getLoginUrl();
|
||||
throw new Error('인증이 만료되었습니다.');
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,21 @@
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// ===== 리다이렉트 루프 방지 =====
|
||||
var REDIRECT_KEY = '_sso_redirect_ts';
|
||||
var REDIRECT_COOLDOWN = 5000; // 5초 내 재리다이렉트 방지
|
||||
|
||||
function safeRedirectToLogin() {
|
||||
var lastRedirect = parseInt(sessionStorage.getItem(REDIRECT_KEY) || '0', 10);
|
||||
var now = Date.now();
|
||||
if (now - lastRedirect < REDIRECT_COOLDOWN) {
|
||||
console.warn('[System2] 리다이렉트 루프 감지 — 로그인 페이지로 이동하지 않음');
|
||||
return;
|
||||
}
|
||||
sessionStorage.setItem(REDIRECT_KEY, String(now));
|
||||
window.location.href = window.getLoginUrl ? window.getLoginUrl() : '/login';
|
||||
}
|
||||
|
||||
// ===== 인증 함수 (api-base.js의 전역 헬퍼 활용) =====
|
||||
function isLoggedIn() {
|
||||
var token = window.getSSOToken ? window.getSSOToken() : localStorage.getItem('sso_token');
|
||||
@@ -28,17 +43,21 @@
|
||||
// 1. 인증 확인
|
||||
if (!isLoggedIn()) {
|
||||
clearAuthData();
|
||||
window.location.href = window.getLoginUrl ? window.getLoginUrl() : '/login';
|
||||
safeRedirectToLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
var currentUser = getUser();
|
||||
if (!currentUser || !currentUser.username) {
|
||||
clearAuthData();
|
||||
window.location.href = window.getLoginUrl ? window.getLoginUrl() : '/login';
|
||||
safeRedirectToLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
// 인증 성공 — 루프 카운터 리셋 + localStorage 백업
|
||||
sessionStorage.removeItem(REDIRECT_KEY);
|
||||
var token = window.getSSOToken ? window.getSSOToken() : null;
|
||||
if (token && !localStorage.getItem('sso_token')) localStorage.setItem('sso_token', token);
|
||||
console.log('[System2] 인증 확인:', currentUser.username);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
<link rel="stylesheet" href="/css/common.css?v=2">
|
||||
<link rel="stylesheet" href="/css/project-management.css?v=3">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="/js/api-base.js?v=20260309"></script>
|
||||
<script src="/js/app-init.js?v=20260309" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
/* 상태 배지 */
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
<link rel="stylesheet" href="/css/common.css?v=2">
|
||||
<link rel="stylesheet" href="/css/project-management.css?v=3">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="/js/api-base.js?v=20260309"></script>
|
||||
<script src="/js/app-init.js?v=20260309" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
/* 통계 카드 */
|
||||
|
||||
@@ -189,7 +189,7 @@
|
||||
<script src="/static/js/core/auth-manager.js?v=20260308"></script>
|
||||
<script src="/static/js/core/permissions.js?v=20260308"></script>
|
||||
<script src="/static/js/utils/issue-helpers.js?v=20260308"></script>
|
||||
<script src="/static/js/m/m-common.js?v=20260308"></script>
|
||||
<script src="/static/js/m/m-common.js?v=20260309"></script>
|
||||
<script src="/static/js/m/m-dashboard.js?v=20260308"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -197,7 +197,7 @@
|
||||
<script src="/static/js/core/auth-manager.js?v=20260308"></script>
|
||||
<script src="/static/js/core/permissions.js?v=20260308"></script>
|
||||
<script src="/static/js/utils/issue-helpers.js?v=20260308"></script>
|
||||
<script src="/static/js/m/m-common.js?v=20260308"></script>
|
||||
<script src="/static/js/m/m-common.js?v=20260309"></script>
|
||||
<script src="/static/js/m/m-inbox.js?v=20260308"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -173,7 +173,7 @@
|
||||
<script src="/static/js/core/auth-manager.js?v=20260308"></script>
|
||||
<script src="/static/js/core/permissions.js?v=20260308"></script>
|
||||
<script src="/static/js/utils/issue-helpers.js?v=20260308"></script>
|
||||
<script src="/static/js/m/m-common.js?v=20260308"></script>
|
||||
<script src="/static/js/m/m-common.js?v=20260309"></script>
|
||||
<script src="/static/js/m/m-management.js?v=20260308"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -11,13 +11,14 @@ function _cookieRemove(name) {
|
||||
document.cookie = cookie;
|
||||
}
|
||||
|
||||
// 중앙 로그인 URL
|
||||
// 중앙 로그인 URL (캐시 버스팅 포함)
|
||||
function _getLoginUrl() {
|
||||
const hostname = window.location.hostname;
|
||||
const t = Date.now();
|
||||
if (hostname.includes('technicalkorea.net')) {
|
||||
return window.location.protocol + '//tkfb.technicalkorea.net/login?redirect=' + encodeURIComponent(window.location.href);
|
||||
return window.location.protocol + '//tkfb.technicalkorea.net/login?redirect=' + encodeURIComponent(window.location.href) + '&_t=' + t;
|
||||
}
|
||||
return window.location.protocol + '//' + hostname + ':30000/login?redirect=' + encodeURIComponent(window.location.href);
|
||||
return window.location.protocol + '//' + hostname + ':30000/login?redirect=' + encodeURIComponent(window.location.href) + '&_t=' + t;
|
||||
}
|
||||
|
||||
// API 기본 설정 (통합 환경 지원)
|
||||
|
||||
@@ -154,7 +154,9 @@ class AuthManager {
|
||||
this.isAuthenticated = true;
|
||||
this.lastAuthCheck = Date.now();
|
||||
|
||||
// localStorage 업데이트
|
||||
// localStorage 업데이트 (쿠키 소실 대비 백업)
|
||||
const token = this._getToken();
|
||||
if (token) localStorage.setItem('sso_token', token);
|
||||
localStorage.setItem('sso_user', JSON.stringify(user));
|
||||
|
||||
this.notifyListeners('auth-success', user);
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
})();
|
||||
|
||||
/* ===== KST Date Utilities ===== */
|
||||
// DB에 KST로 저장된 naive datetime을 그대로 표시 (이중 변환 방지)
|
||||
function getKSTDate(date) {
|
||||
var d = new Date(date);
|
||||
return new Date(d.getTime() + 9 * 60 * 60 * 1000);
|
||||
return new Date(date);
|
||||
}
|
||||
function formatKSTDate(date) {
|
||||
return new Date(date).toLocaleDateString('ko-KR', { timeZone: 'Asia/Seoul' });
|
||||
@@ -27,13 +27,12 @@ function formatKSTDateTime(date) {
|
||||
return new Date(date).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
function getKSTToday() {
|
||||
var now = new Date();
|
||||
var kst = getKSTDate(now);
|
||||
var kst = new Date(new Date().toLocaleString('en-US', { timeZone: 'Asia/Seoul' }));
|
||||
return new Date(kst.getFullYear(), kst.getMonth(), kst.getDate());
|
||||
}
|
||||
function getTimeAgo(date) {
|
||||
var now = getKSTDate(new Date());
|
||||
var d = getKSTDate(date);
|
||||
var now = new Date();
|
||||
var d = new Date(date);
|
||||
var diff = now - d;
|
||||
var mins = Math.floor(diff / 60000);
|
||||
var hours = Math.floor(diff / 3600000);
|
||||
@@ -147,20 +146,45 @@ function closePhotoModal() {
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Auth Helper ===== */
|
||||
/* ===== Auth Helper (authManager 위임 + 루프 방지) ===== */
|
||||
var _mRedirectKey = '_sso_redirect_ts';
|
||||
var _mRedirectCooldown = 5000; // 5초 내 재리다이렉트 방지
|
||||
|
||||
function _mSafeRedirectToLogin() {
|
||||
var last = parseInt(sessionStorage.getItem(_mRedirectKey) || '0', 10);
|
||||
if (Date.now() - last < _mRedirectCooldown) {
|
||||
console.warn('[TKQC-M] 리다이렉트 루프 감지 — 로그인 페이지로 이동하지 않음');
|
||||
return;
|
||||
}
|
||||
sessionStorage.setItem(_mRedirectKey, String(Date.now()));
|
||||
window.location.href = _getLoginUrl();
|
||||
}
|
||||
|
||||
async function mCheckAuth() {
|
||||
// authManager가 있으면 위임 (SW 정리 + 캐시 관리 포함)
|
||||
if (window.authManager && typeof window.authManager.checkAuth === 'function') {
|
||||
var user = await window.authManager.checkAuth();
|
||||
if (user) {
|
||||
sessionStorage.removeItem(_mRedirectKey);
|
||||
return user;
|
||||
}
|
||||
_mSafeRedirectToLogin();
|
||||
return null;
|
||||
}
|
||||
// 폴백: authManager 없는 경우
|
||||
var token = TokenManager.getToken();
|
||||
if (!token) {
|
||||
window.location.href = _getLoginUrl();
|
||||
_mSafeRedirectToLogin();
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
var user = await AuthAPI.getCurrentUser();
|
||||
sessionStorage.removeItem(_mRedirectKey);
|
||||
return user;
|
||||
} catch (e) {
|
||||
TokenManager.removeToken();
|
||||
TokenManager.removeUser();
|
||||
window.location.href = _getLoginUrl();
|
||||
_mSafeRedirectToLogin();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ function requireAdmin(req, res, next) {
|
||||
}
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
if (!['admin', 'system'].includes(decoded.role)) {
|
||||
if (!['admin', 'system'].includes((decoded.role || '').toLowerCase())) {
|
||||
return res.status(403).json({ success: false, error: '관리자 권한이 필요합니다' });
|
||||
}
|
||||
req.user = decoded;
|
||||
|
||||
@@ -1388,7 +1388,7 @@
|
||||
</div>
|
||||
|
||||
<!-- JS: Core (config, token, api, toast, helpers, init) -->
|
||||
<script src="/static/js/tkuser-core.js?v=20260224"></script>
|
||||
<script src="/static/js/tkuser-core.js?v=20260309"></script>
|
||||
<!-- JS: Tabs -->
|
||||
<script src="/static/js/tkuser-tabs.js?v=20260224"></script>
|
||||
<!-- JS: Individual modules -->
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/* ===== 서비스 워커 해제 (캐시 간섭으로 인한 인증 루프 방지) ===== */
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.getRegistrations().then(function(regs) { regs.forEach(function(r) { r.unregister(); }); });
|
||||
if (typeof caches !== 'undefined') { caches.keys().then(function(ns) { ns.forEach(function(n) { caches.delete(n); }); }); }
|
||||
}
|
||||
|
||||
/* ===== Config ===== */
|
||||
const API_BASE = '/api';
|
||||
|
||||
@@ -7,16 +13,26 @@ function _cookieRemove(n) { let c = n + '=; path=/; max-age=0'; if (location.hos
|
||||
function getToken() { return _cookieGet('sso_token') || localStorage.getItem('sso_token'); }
|
||||
function getLoginUrl() {
|
||||
const h = location.hostname;
|
||||
if (h.includes('technicalkorea.net')) return location.protocol + '//tkfb.technicalkorea.net/login?redirect=' + encodeURIComponent(location.href);
|
||||
return location.protocol + '//' + h + ':30000/login?redirect=' + encodeURIComponent(location.href);
|
||||
const t = Date.now(); // 캐시 버스팅
|
||||
if (h.includes('technicalkorea.net')) return location.protocol + '//tkfb.technicalkorea.net/login?redirect=' + encodeURIComponent(location.href) + '&_t=' + t;
|
||||
return location.protocol + '//' + h + ':30000/login?redirect=' + encodeURIComponent(location.href) + '&_t=' + t;
|
||||
}
|
||||
function decodeToken(t) { try { return JSON.parse(atob(t.split('.')[1].replace(/-/g,'+').replace(/_/g,'/'))); } catch { return null; } }
|
||||
|
||||
/* ===== 리다이렉트 루프 방지 ===== */
|
||||
const _REDIRECT_KEY = '_sso_redirect_ts';
|
||||
function _safeRedirect() {
|
||||
const last = parseInt(sessionStorage.getItem(_REDIRECT_KEY) || '0', 10);
|
||||
if (Date.now() - last < 5000) { console.warn('[tkuser] 리다이렉트 루프 감지'); return; }
|
||||
sessionStorage.setItem(_REDIRECT_KEY, String(Date.now()));
|
||||
location.href = getLoginUrl();
|
||||
}
|
||||
|
||||
/* ===== API ===== */
|
||||
async function api(path, opts = {}) {
|
||||
const token = getToken();
|
||||
const res = await fetch(API_BASE + path, { ...opts, headers: { 'Content-Type': 'application/json', 'Authorization': token ? `Bearer ${token}` : '', ...(opts.headers||{}) } });
|
||||
if (res.status === 401) { location.href = getLoginUrl(); throw new Error('인증 만료'); }
|
||||
if (res.status === 401) { throw new Error('인증 만료'); }
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || data.detail || '요청 실패');
|
||||
return data;
|
||||
@@ -68,23 +84,31 @@ let currentUser = null;
|
||||
/* ===== Init ===== */
|
||||
async function init() {
|
||||
const token = getToken();
|
||||
if (!token) { location.href = getLoginUrl(); return; }
|
||||
if (!token) { _safeRedirect(); return; }
|
||||
const decoded = decodeToken(token);
|
||||
if (!decoded) { location.href = getLoginUrl(); return; }
|
||||
if (!decoded) { _safeRedirect(); return; }
|
||||
sessionStorage.removeItem(_REDIRECT_KEY);
|
||||
|
||||
currentUser = { id: decoded.user_id||decoded.id, username: decoded.username||decoded.sub, name: decoded.name||decoded.full_name, role: decoded.role||decoded.access_level };
|
||||
// 쿠키에서 읽었으면 localStorage에도 백업 (다음 방문 시 쿠키 소실 대비)
|
||||
if (!localStorage.getItem('sso_token')) localStorage.setItem('sso_token', token);
|
||||
|
||||
currentUser = { id: decoded.user_id||decoded.id, username: decoded.username||decoded.sub, name: decoded.name||decoded.full_name, role: (decoded.role||decoded.access_level||'').toLowerCase() };
|
||||
const dn = currentUser.name || currentUser.username;
|
||||
document.getElementById('headerUserName').textContent = dn;
|
||||
document.getElementById('headerUserRole').textContent = currentUser.role === 'admin' ? '관리자' : '사용자';
|
||||
document.getElementById('headerUserAvatar').textContent = dn.charAt(0).toUpperCase();
|
||||
|
||||
if (currentUser.role === 'admin') {
|
||||
document.getElementById('tabNav').classList.remove('hidden');
|
||||
document.getElementById('adminSection').classList.remove('hidden');
|
||||
await loadDepartmentsCache();
|
||||
await loadUsers();
|
||||
} else {
|
||||
document.getElementById('passwordChangeSection').classList.remove('hidden');
|
||||
try {
|
||||
if (currentUser.role === 'admin' || currentUser.role === 'system') {
|
||||
document.getElementById('tabNav').classList.remove('hidden');
|
||||
document.getElementById('adminSection').classList.remove('hidden');
|
||||
await loadDepartmentsCache();
|
||||
await loadUsers();
|
||||
} else {
|
||||
document.getElementById('passwordChangeSection').classList.remove('hidden');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[tkuser] init 오류:', e);
|
||||
}
|
||||
setTimeout(() => document.querySelector('.fade-in').classList.add('visible'), 50);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user