fix: tkfb 로그인을 SSO 인증으로 변경

system1-factory의 자체 로그인 폼을 제거하고 게이트웨이 SSO 로그인 페이지(/login)로
리다이렉트하도록 변경. 기존에는 /api/auth/login(system1-api)으로 직접 인증하여
SSO 사용자가 401 오류를 받았음.

- index.html: 로그인 폼 제거, SSO 토큰 없으면 /login으로 리다이렉트
- api-base.js: getLoginUrl() 개발환경에서도 SSO 로그인 경로 반환
- api-helper.js: authFetch 401/토큰없음 시 SSO 로그인으로 리다이렉트
- app-init.js: 로그아웃 및 인증실패 시 SSO 로그인으로 리다이렉트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-04 14:41:27 +09:00
parent 6e5c29c73a
commit 8fd74ad22f
4 changed files with 112 additions and 174 deletions

View File

@@ -1,15 +1,29 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>(주)테크니컬코리아 생산팀 포털</title>
<link rel="icon" type="image/png" href="img/favicon.png">
<script>
window.location.replace('/pages/dashboard.html');
</script>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>(주)테크니컬코리아 생산팀 포털</title>
<link rel="icon" type="image/png" href="/img/favicon.png">
<!-- SW 캐시 강제 해제 (Chrome 대응) -->
<script>
if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then(function(r){r.forEach(function(reg){reg.unregister()});})}
if('caches' in window){caches.keys().then(function(k){k.forEach(function(key){caches.delete(key)})})}
</script>
<script src="/js/api-base.js?v=3"></script>
<script>
// SSO 토큰 확인
var token = window.getSSOToken ? window.getSSOToken() : (localStorage.getItem('sso_token') || localStorage.getItem('token'));
if (token && token !== 'undefined' && token !== 'null') {
// 이미 로그인된 경우 대시보드로 이동
window.location.replace('/pages/dashboard.html');
} else {
// SSO 로그인 페이지로 리다이렉트 (gateway의 /login)
window.location.replace('/login?redirect=' + encodeURIComponent('/pages/dashboard.html'));
}
</script>
</head>
<body>
<p>로딩 중...</p>
<!-- SSO 로그인 페이지로 자동 리다이렉트됩니다 -->
</body>
</html>

View File

@@ -20,9 +20,10 @@
/**
* SSO 토큰 가져오기 (쿠키 우선, localStorage 폴백)
* sso_token이 없으면 기존 token도 확인 (하위 호환)
*/
window.getSSOToken = function() {
return cookieGet('sso_token') || localStorage.getItem('sso_token');
return cookieGet('sso_token') || localStorage.getItem('sso_token') || localStorage.getItem('token');
};
/**
@@ -30,6 +31,10 @@
*/
window.getSSOUser = function() {
var raw = cookieGet('sso_user') || localStorage.getItem('sso_user');
if (!raw) {
// 기존 user 키도 확인 (하위 호환)
raw = localStorage.getItem('user');
}
try { return raw ? JSON.parse(raw) : null; } catch(e) { return null; }
};
@@ -41,7 +46,8 @@
if (hostname.includes('technicalkorea.net')) {
return window.location.protocol + '//tkfb.technicalkorea.net/login?redirect=' + encodeURIComponent(window.location.href);
}
return '/login';
// 개발 환경: 게이트웨이 SSO 로그인 페이지
return '/login?redirect=' + encodeURIComponent(window.location.href);
};
/**
@@ -54,6 +60,9 @@
localStorage.removeItem('sso_token');
localStorage.removeItem('sso_user');
localStorage.removeItem('sso_refresh_token');
// 기존 키도 삭제 (하위 호환)
localStorage.removeItem('token');
localStorage.removeItem('user');
localStorage.removeItem('userPageAccess');
};

View File

@@ -6,13 +6,18 @@ const API_BASE_URL = window.API_BASE_URL || 'http://localhost:30005/api';
// 인증 관련 함수들 (직접 구현)
function getToken() {
const token = localStorage.getItem('sso_token');
// SSO 토큰 우선, 기존 token 폴백
if (window.getSSOToken) return window.getSSOToken();
const token = localStorage.getItem('sso_token') || localStorage.getItem('token');
return token && token !== 'undefined' && token !== 'null' ? token : null;
}
function clearAuthData() {
if (window.clearSSOAuth) { window.clearSSOAuth(); return; }
localStorage.removeItem('sso_token');
localStorage.removeItem('sso_user');
localStorage.removeItem('token');
localStorage.removeItem('user');
}
/**
@@ -45,11 +50,11 @@ async function login(username, password) {
*/
async function authFetch(endpoint, options = {}) {
const token = getToken();
if (!token) {
console.error('토큰이 없습니다. 로그인이 필요합니다.');
clearAuthData(); // 인증 정보 정리
window.location.href = '/login'; // 로그인 페이지로 리디렉션
window.location.href = window.getLoginUrl ? window.getLoginUrl() : '/login?redirect=' + encodeURIComponent(window.location.href);
// 에러를 던져서 후속 실행을 중단
throw new Error('인증 토큰이 없습니다.');
}
@@ -71,7 +76,7 @@ async function authFetch(endpoint, options = {}) {
if (response.status === 401) {
console.error('인증 실패. 토큰이 만료되었거나 유효하지 않습니다.');
clearAuthData(); // 만료된 인증 정보 정리
window.location.href = '/login';
window.location.href = window.getLoginUrl ? window.getLoginUrl() : '/login?redirect=' + encodeURIComponent(window.location.href);
throw new Error('인증에 실패했습니다.');
}
@@ -133,4 +138,4 @@ window.apiPost = apiPost;
window.apiPut = apiPut;
window.apiDelete = apiDelete;
window.getToken = getToken;
window.clearAuthData = clearAuthData;
window.clearAuthData = clearAuthData;

View File

@@ -1,6 +1,7 @@
// /js/app-init.js
// 앱 초기화 - 인증, 네비바, 사이드바를 한 번에 로드
// 모든 페이지에서 이 하나의 스크립트만 로드하면 됨
// api-base.js가 먼저 로드되어야 함 (getSSOToken, getSSOUser, clearSSOAuth 등)
(function() {
'use strict';
@@ -9,24 +10,29 @@
const CACHE_DURATION = 10 * 60 * 1000; // 10분
const COMPONENT_CACHE_PREFIX = 'component_v3_';
// ===== 인증 함수 (api-base.js의 전역 헬퍼 활용) =====
// ===== 인증 함수 (api-base.js의 SSO 함수 활용) =====
function isLoggedIn() {
var token = window.getSSOToken ? window.getSSOToken() : localStorage.getItem('sso_token');
const token = window.getSSOToken ? window.getSSOToken() : (localStorage.getItem('sso_token') || localStorage.getItem('token'));
return token && token !== 'undefined' && token !== 'null';
}
function getUser() {
return window.getSSOUser ? window.getSSOUser() : (function() {
var u = localStorage.getItem('sso_user');
return u ? JSON.parse(u) : null;
})();
if (window.getSSOUser) return window.getSSOUser();
const user = localStorage.getItem('sso_user') || localStorage.getItem('user');
try { return user ? JSON.parse(user) : null; } catch(e) { return null; }
}
function getToken() {
return window.getSSOToken ? window.getSSOToken() : (localStorage.getItem('sso_token') || localStorage.getItem('token'));
}
function clearAuthData() {
if (window.clearSSOAuth) { window.clearSSOAuth(); return; }
localStorage.removeItem('sso_token');
localStorage.removeItem('sso_user');
localStorage.removeItem('userPageAccess_v2');
localStorage.removeItem('token');
localStorage.removeItem('user');
localStorage.removeItem('userPageAccess');
}
// ===== 페이지 권한 캐시 =====
@@ -36,7 +42,7 @@
if (!currentUser || !currentUser.user_id) return null;
// 캐시 확인
const cached = localStorage.getItem('userPageAccess_v2');
const cached = localStorage.getItem('userPageAccess');
if (cached) {
try {
const cacheData = JSON.parse(cached);
@@ -44,7 +50,7 @@
return cacheData.pages;
}
} catch (e) {
localStorage.removeItem('userPageAccess_v2');
localStorage.removeItem('userPageAccess');
}
}
@@ -54,11 +60,12 @@
// 새로운 API 호출
pageAccessPromise = (async () => {
try {
const token = getToken();
const response = await fetch(`${window.API_BASE_URL}/users/${currentUser.user_id}/page-access`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + (window.getSSOToken ? window.getSSOToken() : localStorage.getItem('sso_token'))
'Authorization': `Bearer ${token}`
}
});
@@ -67,7 +74,7 @@
const data = await response.json();
const pages = data.data.pageAccess || [];
localStorage.setItem('userPageAccess_v2', JSON.stringify({
localStorage.setItem('userPageAccess', JSON.stringify({
pages: pages,
timestamp: Date.now()
}));
@@ -87,15 +94,17 @@
async function getAccessiblePageKeys(currentUser) {
const pages = await getPageAccess(currentUser);
if (!pages) return [];
return pages.filter(p => p.can_access === 1).map(p => p.page_key);
return pages.filter(p => p.can_access == 1).map(p => p.page_key);
}
// ===== 현재 페이지 키 추출 =====
// 하위 페이지 → 부모 페이지 키 매핑 (동일 권한 공유)
var PAGE_KEY_ALIASES = {
'work.tbm-create': 'work.tbm',
const PAGE_KEY_ALIASES = {
'work.tbm-mobile': 'work.tbm',
'work.report-create-mobile': 'work.report-create'
'work.tbm-create': 'work.tbm',
'work.report-create-mobile': 'work.report-create',
'admin.equipment-detail': 'admin.equipments',
'safety.issue-detail': 'safety.issue-report'
};
function getCurrentPageKey() {
@@ -186,7 +195,6 @@
async function processSidebar(doc, currentUser, accessiblePageKeys) {
const userRole = (currentUser.role || '').toLowerCase();
const accessLevel = (currentUser.access_level || '').toLowerCase();
// role 또는 access_level로 관리자 확인
const isAdmin = userRole === 'admin' || userRole === 'system admin' || userRole === 'system' ||
accessLevel === 'admin' || accessLevel === 'system';
@@ -212,26 +220,6 @@
}
});
// 크로스 시스템 링크 URL 설정
var hostname = window.location.hostname;
var protocol = window.location.protocol;
var systemUrls = {};
if (hostname.includes('technicalkorea.net')) {
systemUrls.report = protocol + '//tkreport.technicalkorea.net';
systemUrls.nc = protocol + '//tkqc.technicalkorea.net';
} else {
systemUrls.report = protocol + '//' + hostname + ':30180';
systemUrls.nc = protocol + '//' + hostname + ':30280';
}
doc.querySelectorAll('.cross-system-link').forEach(function(link) {
var system = link.getAttribute('data-system');
var path = link.getAttribute('data-path');
if (systemUrls[system]) {
link.setAttribute('href', systemUrls[system] + path);
link.setAttribute('target', '_blank');
}
});
// 저장된 상태 복원 (기본값: 접힌 상태)
const isCollapsed = localStorage.getItem('sidebarCollapsed') !== 'false';
const sidebar = doc.querySelector('.sidebar-nav');
@@ -281,7 +269,8 @@
logoutButton.addEventListener('click', () => {
if (confirm('로그아웃 하시겠습니까?')) {
clearAuthData();
window.location.href = window.getLoginUrl ? window.getLoginUrl() : '/login';
if (window.clearSSOAuth) window.clearSSOAuth();
window.location.href = window.getLoginUrl ? window.getLoginUrl() : '/login?redirect=' + encodeURIComponent('/pages/dashboard.html');
}
});
}
@@ -308,7 +297,7 @@
// ===== 알림 로드 =====
async function loadNotifications() {
try {
const token = window.getSSOToken ? window.getSSOToken() : localStorage.getItem('sso_token');
const token = getToken();
if (!token) return;
const response = await fetch(`${window.API_BASE_URL}/notifications/unread`, {
@@ -351,11 +340,11 @@
return;
}
const icons = { repair: '🔧', safety: '⚠️', system: '📢', equipment: '🔩', maintenance: '🛠️' };
const icons = { repair: '\ud83d\udd27', safety: '\u26a0\ufe0f', system: '\ud83d\udce2', equipment: '\ud83d\udea9', maintenance: '\ud83d\udee0\ufe0f' };
list.innerHTML = notifications.slice(0, 5).map(n => `
<div class="notification-item ${n.is_read ? '' : 'unread'}" data-id="${n.notification_id}" data-url="${n.link_url || ''}">
<div class="notification-item-icon ${n.type || 'repair'}">${icons[n.type] || '🔔'}</div>
<div class="notification-item-icon ${n.type || 'repair'}">${icons[n.type] || '\ud83d\udd14'}</div>
<div class="notification-item-content">
<div class="notification-item-title">${escapeHtml(n.title)}</div>
<div class="notification-item-desc">${escapeHtml(n.message || '')}</div>
@@ -367,7 +356,6 @@
list.querySelectorAll('.notification-item').forEach(item => {
item.addEventListener('click', () => {
const url = item.dataset.url;
// 수리 알림은 클릭해도 읽음 처리 안함 (수리 처리 페이지에서 확인 처리해야 함)
window.location.href = url || '/pages/admin/notifications.html';
});
});
@@ -388,8 +376,12 @@
return date.toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' });
}
// escapeHtml은 api-base.js에서 window.escapeHtml로 전역 제공
var escapeHtml = window.escapeHtml;
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// ===== 날짜/시간 업데이트 =====
function updateDateTime() {
@@ -410,12 +402,12 @@
}
// ===== 날씨 업데이트 =====
const WEATHER_ICONS = { clear: '☀️', rain: '🌧️', snow: '❄️', heat: '🔥', cold: '🥶', wind: '💨', fog: '🌫️', dust: '😷', cloudy: '', overcast: '☁️' };
const WEATHER_ICONS = { clear: '\u2600\ufe0f', rain: '\ud83c\udf27\ufe0f', snow: '\u2744\ufe0f', heat: '\ud83e\udd75', cold: '\ud83e\udd76', wind: '\ud83c\udf2c\ufe0f', fog: '\ud83c\udf2b\ufe0f', dust: '\ud83d\ude37', cloudy: '\u26c5', overcast: '\u2601\ufe0f' };
const WEATHER_NAMES = { clear: '맑음', rain: '비', snow: '눈', heat: '폭염', cold: '한파', wind: '강풍', fog: '안개', dust: '미세먼지', cloudy: '구름많음', overcast: '흐림' };
async function updateWeather() {
try {
const token = window.getSSOToken ? window.getSSOToken() : localStorage.getItem('sso_token');
const token = getToken();
if (!token) return;
// 캐시 확인
@@ -446,7 +438,7 @@
const descEl = document.getElementById('weatherDesc');
if (conditions && conditions.length > 0) {
const primary = conditions[0];
if (iconEl) iconEl.textContent = WEATHER_ICONS[primary] || '🌤️';
if (iconEl) iconEl.textContent = WEATHER_ICONS[primary] || '\ud83c\udf24\ufe0f';
if (descEl) descEl.textContent = WEATHER_NAMES[primary] || '맑음';
}
}
@@ -457,72 +449,60 @@
// ===== 메인 초기화 =====
async function init() {
console.log('🚀 app-init 시작');
// 1. 인증 확인
if (!isLoggedIn()) {
clearAuthData();
window.location.href = window.getLoginUrl ? window.getLoginUrl() : '/login';
window.location.href = window.getLoginUrl ? window.getLoginUrl() : '/login?redirect=' + encodeURIComponent(window.location.href);
return;
}
const currentUser = getUser();
if (!currentUser || !currentUser.username) {
clearAuthData();
window.location.href = window.getLoginUrl ? window.getLoginUrl() : '/login';
window.location.href = window.getLoginUrl ? window.getLoginUrl() : '/login?redirect=' + encodeURIComponent(window.location.href);
return;
}
console.log('✅ 인증 확인:', currentUser.username);
const userRole = (currentUser.role || '').toLowerCase();
const accessLevel = (currentUser.access_level || '').toLowerCase();
// role 또는 access_level로 관리자 확인
const isAdmin = userRole === 'admin' || userRole === 'system admin' || userRole === 'system' ||
accessLevel === 'admin' || accessLevel === 'system';
// 2. 페이지 접근 권한 체크 (Admin은 건너뛰기)
// 2. 페이지 접근 권한 체크 (Admin은 건너뛰기, API 실패시 허용)
let accessiblePageKeys = [];
const pageKey = getCurrentPageKey();
if (!isAdmin) {
const pageKey = getCurrentPageKey();
if (pageKey && pageKey !== 'dashboard' && !pageKey.startsWith('profile.')) {
accessiblePageKeys = await getAccessiblePageKeys(currentUser);
if (!accessiblePageKeys.includes(pageKey)) {
alert('이 페이지에 접근할 권한이 없습니다.');
window.location.href = '/pages/dashboard.html';
return;
const pages = await getPageAccess(currentUser);
if (pages) {
accessiblePageKeys = pages.filter(p => p.can_access == 1).map(p => p.page_key);
if (pageKey && pageKey !== 'dashboard' && !pageKey.startsWith('profile.')) {
if (!accessiblePageKeys.includes(pageKey)) {
alert('이 페이지에 접근할 권한이 없습니다.');
window.location.href = '/pages/dashboard.html';
return;
}
}
}
}
// 3. 네비바 로드 (모바일이면 사이드바 스킵)
var isMobile = window.innerWidth <= 768;
if (!isMobile) {
// 데스크톱: 사이드바 컨테이너 생성 및 로드
let sidebarContainer = document.getElementById('sidebar-container');
if (!sidebarContainer) {
sidebarContainer = document.createElement('div');
sidebarContainer.id = 'sidebar-container';
document.body.prepend(sidebarContainer);
}
console.log('📥 컴포넌트 로딩 시작 (데스크톱: 네비바+사이드바)');
await Promise.all([
loadComponent('navbar', '#navbar-container', (doc) => processNavbar(doc, currentUser, accessiblePageKeys)),
loadComponent('sidebar-nav', '#sidebar-container', (doc) => processSidebar(doc, currentUser, accessiblePageKeys))
]);
setupNavbarEvents();
setupSidebarEvents();
document.body.classList.add('has-sidebar');
} else {
// 모바일: 네비바만 로드, 사이드바 없음
console.log('📥 컴포넌트 로딩 시작 (모바일: 네비바만)');
await loadComponent('navbar', '#navbar-container', (doc) => processNavbar(doc, currentUser, accessiblePageKeys));
setupNavbarEvents();
// 3. 사이드바 컨테이너 생성 (없으면)
let sidebarContainer = document.getElementById('sidebar-container');
if (!sidebarContainer) {
sidebarContainer = document.createElement('div');
sidebarContainer.id = 'sidebar-container';
document.body.prepend(sidebarContainer);
}
console.log('✅ 컴포넌트 로딩 완료');
// 4. 네비바와 사이드바 동시 로드
await Promise.all([
loadComponent('navbar', '#navbar-container', (doc) => processNavbar(doc, currentUser, accessiblePageKeys)),
loadComponent('sidebar-nav', '#sidebar-container', (doc) => processSidebar(doc, currentUser, accessiblePageKeys))
]);
// 5. 이벤트 설정
setupNavbarEvents();
setupSidebarEvents();
document.body.classList.add('has-sidebar');
// 6. 페이지 전환 로딩 인디케이터 설정
setupPageTransitionLoader();
@@ -537,73 +517,10 @@
// 9. 알림 로드 (30초마다 갱신)
setTimeout(loadNotifications, 200);
setInterval(loadNotifications, 30000);
// 10. PWA 설정 (manifest + 서비스 워커 + iOS 메타태그)
setupPWA();
console.log('✅ app-init 완료');
}
// ===== PWA 설정 =====
function setupPWA() {
// manifest.json 동적 추가
if (!document.querySelector('link[rel="manifest"]')) {
var manifest = document.createElement('link');
manifest.rel = 'manifest';
manifest.href = '/manifest.json';
document.head.appendChild(manifest);
}
// iOS 홈 화면 앱 메타태그
if (!document.querySelector('meta[name="apple-mobile-web-app-capable"]')) {
var metaTags = [
{ name: 'apple-mobile-web-app-capable', content: 'yes' },
{ name: 'apple-mobile-web-app-status-bar-style', content: 'default' },
{ name: 'apple-mobile-web-app-title', content: 'TK공장' },
{ name: 'theme-color', content: '#1e40af' }
];
metaTags.forEach(function(tag) {
var meta = document.createElement('meta');
meta.name = tag.name;
meta.content = tag.content;
document.head.appendChild(meta);
});
// iOS 아이콘
var appleIcon = document.createElement('link');
appleIcon.rel = 'apple-touch-icon';
appleIcon.href = '/img/icon-192x192.png';
document.head.appendChild(appleIcon);
}
// 서비스 워커 등록 (킬스위치 포함)
if ('serviceWorker' in navigator) {
// 킬스위치: ?sw-kill 파라미터로 서비스 워커 해제
if (window.location.search.includes('sw-kill')) {
navigator.serviceWorker.getRegistrations().then(function(regs) {
regs.forEach(function(r) { r.unregister(); });
caches.keys().then(function(keys) {
keys.forEach(function(k) { caches.delete(k); });
});
console.log('SW 해제 완료');
window.location.replace(window.location.pathname);
});
return;
}
navigator.serviceWorker.register('/sw.js')
.then(function(reg) {
console.log('SW 등록 완료');
})
.catch(function(err) {
console.warn('SW 등록 실패:', err);
});
}
}
// ===== 페이지 전환 로딩 인디케이터 =====
function setupPageTransitionLoader() {
// 로딩 바 스타일 추가
const style = document.createElement('style');
style.textContent = `
#page-loader {
@@ -634,12 +551,10 @@
`;
document.head.appendChild(style);
// 로딩 바 엘리먼트 생성
const loader = document.createElement('div');
loader.id = 'page-loader';
document.body.appendChild(loader);
// 모든 내부 링크에 클릭 이벤트 추가
document.addEventListener('click', (e) => {
const link = e.target.closest('a');
if (!link) return;
@@ -647,19 +562,14 @@
const href = link.getAttribute('href');
if (!href) return;
// 외부 링크, 해시 링크, javascript: 링크 제외
if (href.startsWith('http') || href.startsWith('#') || href.startsWith('javascript:')) return;
// 새 탭 링크 제외
if (link.target === '_blank') return;
// 로딩 시작
loader.classList.remove('done');
loader.classList.add('loading');
document.body.classList.add('page-loading');
});
// 페이지 떠날 때 완료 표시
window.addEventListener('beforeunload', () => {
const loader = document.getElementById('page-loader');
if (loader) {
@@ -677,5 +587,5 @@
}
// 전역 노출 (필요시)
window.appInit = { getUser, clearAuthData, isLoggedIn };
window.appInit = { getUser, getToken, clearAuthData, isLoggedIn };
})();