diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index daefa1c7..97b868e4 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -4,14 +4,18 @@ use App\Http\Controllers\Controller; use App\Http\Requests\Auth\LoginRequest; +use App\Services\ApiTokenService; use App\Services\AuthService; +use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; +use Illuminate\Support\Facades\Auth; use Illuminate\View\View; class LoginController extends Controller { public function __construct( - private readonly AuthService $authService + private readonly AuthService $authService, + private readonly ApiTokenService $apiTokenService ) {} /** @@ -59,4 +63,91 @@ public function logout(): RedirectResponse return redirect('/login') ->with('success', '로그아웃되었습니다.'); } + + /** + * 세션 갱신 (Remember Token으로 재인증) + * HTMX 401 에러 시 호출되어 세션을 갱신합니다. + */ + public function refreshSession(): JsonResponse + { + // 이미 인증된 경우 (세션이 아직 유효한 경우) + if (Auth::check()) { + return response()->json([ + 'success' => true, + 'message' => '세션이 유효합니다.', + ]); + } + + // Remember Token으로 재인증 시도 + // Laravel의 Auth::viaRemember()는 remember token 쿠키로 인증 시도 + if (Auth::viaRemember()) { + $user = Auth::user(); + + // HQ 테넌트 소속 확인 + if (! $user->belongsToHQ()) { + Auth::logout(); + + return response()->json([ + 'success' => false, + 'message' => '본사 소속 직원만 접근할 수 있습니다.', + 'redirect' => '/login', + ], 401); + } + + // 활성 상태 확인 + if (! $user->is_active) { + Auth::logout(); + + return response()->json([ + 'success' => false, + 'message' => '비활성화된 계정입니다.', + 'redirect' => '/login', + ], 401); + } + + // HQ 테넌트를 기본 선택 + $hqTenant = $user->getHQTenant(); + if ($hqTenant) { + session(['selected_tenant_id' => $hqTenant->id]); + + // API 토큰 재발급 + $this->refreshApiToken($user->id, $hqTenant->id); + } + + return response()->json([ + 'success' => true, + 'message' => '세션이 갱신되었습니다.', + ]); + } + + // Remember Token이 없거나 유효하지 않은 경우 + return response()->json([ + 'success' => false, + 'message' => '세션이 만료되었습니다. 다시 로그인해주세요.', + 'redirect' => '/login', + ], 401); + } + + /** + * API 토큰 재발급 + */ + private function refreshApiToken(int $userId, int $tenantId): void + { + try { + $result = $this->apiTokenService->exchangeToken($userId, $tenantId); + + if ($result['success']) { + $this->apiTokenService->storeTokenInSession( + $result['data']['access_token'], + $result['data']['expires_in'] + ); + } + } catch (\Exception $e) { + // API 토큰 재발급 실패해도 세션 갱신은 계속 진행 + \Log::warning('[LoginController] API token refresh failed', [ + 'user_id' => $userId, + 'error' => $e->getMessage(), + ]); + } + } } diff --git a/public/js/menu-sortable.js b/public/js/menu-sortable.js new file mode 100644 index 00000000..3fd6ff06 --- /dev/null +++ b/public/js/menu-sortable.js @@ -0,0 +1,303 @@ +/** + * 메뉴 관리 페이지 전용 SortableJS 초기화 스크립트 + * HTMX hx-boost 네비게이션에서도 동작하도록 전역으로 정의 + */ + +// 드래그 상태 관리 +let dragStartX = 0; +let dragIndicator = null; +let currentDragItem = null; +let lastHighlightedRow = null; +const INDENT_THRESHOLD = 40; // px - 인덴트 임계값 + +// 드래그 인디케이터 생성 +function createDragIndicator() { + const indicator = document.createElement('div'); + indicator.className = 'drag-indicator reorder'; + indicator.innerHTML = '↕️ 순서 변경'; + indicator.style.display = 'none'; + document.body.appendChild(indicator); + return indicator; +} + +// 드래그 인디케이터 업데이트 +function updateDragIndicator(deltaX, mouseX, mouseY, canIndent, canOutdent, targetName) { + if (!dragIndicator) return; + + dragIndicator.style.display = 'block'; + dragIndicator.style.left = (mouseX + 15) + 'px'; + dragIndicator.style.top = (mouseY - 15) + 'px'; + + // 클래스 및 텍스트 업데이트 + dragIndicator.classList.remove('indent', 'outdent', 'reorder'); + + if (deltaX > INDENT_THRESHOLD && canIndent) { + dragIndicator.classList.add('indent'); + dragIndicator.innerHTML = `→ ${targetName}의 하위로`; + } else if (deltaX < -INDENT_THRESHOLD && canOutdent) { + dragIndicator.classList.add('outdent'); + dragIndicator.innerHTML = '← 상위 레벨로'; + } else { + dragIndicator.classList.add('reorder'); + dragIndicator.innerHTML = '↕️ 순서 변경'; + } +} + +// 행 하이라이트 업데이트 +function updateRowHighlight(prevRow, draggedRow, deltaX, canIndent, canOutdent) { + // 이전 하이라이트 제거 + if (lastHighlightedRow) { + lastHighlightedRow.classList.remove('drag-target-indent', 'drag-target-outdent'); + } + + // 새 하이라이트 적용 + if (deltaX > INDENT_THRESHOLD && canIndent && prevRow) { + // 인덴트: prevRow(새 부모가 될 행) 하이라이트 + prevRow.classList.add('drag-target-indent'); + lastHighlightedRow = prevRow; + } else if (deltaX < -INDENT_THRESHOLD && canOutdent && draggedRow) { + // 아웃덴트: 드래그 중인 행 자체 하이라이트 + draggedRow.classList.add('drag-target-outdent'); + lastHighlightedRow = draggedRow; + } else { + lastHighlightedRow = null; + } +} + +// 드래그 인디케이터 제거 +function removeDragIndicator() { + if (dragIndicator) { + dragIndicator.remove(); + dragIndicator = null; + } + if (lastHighlightedRow) { + lastHighlightedRow.classList.remove('drag-target-indent', 'drag-target-outdent'); + lastHighlightedRow = null; + } +} + +// SortableJS 초기화 함수 (전역) +window.initMenuSortable = function() { + const tbody = document.getElementById('menu-sortable'); + if (!tbody) return; + + // Sortable 라이브러리 체크 + if (typeof Sortable === 'undefined') { + console.warn('SortableJS not loaded'); + return; + } + + // 기존 인스턴스 제거 + if (tbody.sortableInstance) { + tbody.sortableInstance.destroy(); + } + + // 드래그 중 마우스 이동 핸들러 + function onDragMove(e) { + if (!currentDragItem) return; + + const mouseX = e.clientX || e.touches?.[0]?.clientX || 0; + const mouseY = e.clientY || e.touches?.[0]?.clientY || 0; + const deltaX = mouseX - dragStartX; + + const rows = Array.from(tbody.querySelectorAll('tr.menu-row')); + const draggedIndex = rows.indexOf(currentDragItem); + + // 위 행 찾기 (드래그 중인 항목 제외) + let prevRow = null; + for (let i = draggedIndex - 1; i >= 0; i--) { + if (rows[i] !== currentDragItem && !rows[i].classList.contains('sortable-ghost')) { + prevRow = rows[i]; + break; + } + } + + // 인덴트/아웃덴트 가능 여부 체크 + const oldParentIdRaw = currentDragItem.dataset.parentId; + const oldPid = oldParentIdRaw === '' ? null : (oldParentIdRaw ? parseInt(oldParentIdRaw) : null); + const canIndent = prevRow !== null; + const canOutdent = oldPid !== null; + + // 타겟 이름 가져오기 + let targetName = ''; + if (prevRow && canIndent) { + const nameCell = prevRow.querySelector('td:nth-child(3) span'); + targetName = nameCell?.textContent?.trim() || '위 항목'; + } + + updateDragIndicator(deltaX, mouseX, mouseY, canIndent, canOutdent, targetName); + updateRowHighlight(prevRow, currentDragItem, deltaX, canIndent, canOutdent); + } + + // CSRF 토큰 가져오기 + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''; + + // SortableJS 초기화 - 노션 스타일 인덴트 + tbody.sortableInstance = new Sortable(tbody, { + handle: '.drag-handle', + animation: 150, + forceFallback: true, + fallbackClass: 'sortable-fallback', + fallbackOnBody: true, + ghostClass: 'bg-blue-50', + chosenClass: 'bg-blue-100', + dragClass: 'shadow-lg', + + // 드래그 시작 시 + onStart: function(evt) { + dragStartX = evt.originalEvent?.clientX || evt.originalEvent?.touches?.[0]?.clientX || 0; + currentDragItem = evt.item; + dragIndicator = createDragIndicator(); + + // 드래그 중 텍스트 선택 방지 + document.body.classList.add('is-dragging'); + + // 마우스 이동 이벤트 리스너 추가 + document.addEventListener('mousemove', onDragMove); + document.addEventListener('touchmove', onDragMove); + }, + + // 드래그 종료 시 처리 + onEnd: function(evt) { + // 이벤트 리스너 및 인디케이터 정리 + document.removeEventListener('mousemove', onDragMove); + document.removeEventListener('touchmove', onDragMove); + removeDragIndicator(); + currentDragItem = null; + + // 드래그 중 텍스트 선택 방지 해제 + document.body.classList.remove('is-dragging'); + + const movedItem = evt.item; + const menuId = parseInt(movedItem.dataset.menuId); + const oldParentIdRaw = movedItem.dataset.parentId; + const oldPid = oldParentIdRaw === '' ? null : (oldParentIdRaw ? parseInt(oldParentIdRaw) : null); + + const rows = Array.from(tbody.querySelectorAll('tr.menu-row')); + const newIndex = rows.indexOf(movedItem); + + // X 이동량 계산 + const dragEndX = evt.originalEvent?.clientX || evt.originalEvent?.changedTouches?.[0]?.clientX || dragStartX; + const deltaX = dragEndX - dragStartX; + + let newParentId = oldPid; + let sortOrder = 1; + + // 오른쪽으로 이동 → 인덴트 (위 항목의 자식으로) + if (deltaX > INDENT_THRESHOLD && newIndex > 0) { + const prevRow = rows[newIndex - 1]; + if (prevRow) { + const prevId = parseInt(prevRow.dataset.menuId); + if (prevId !== menuId) { + newParentId = prevId; + } + } + } + // 왼쪽으로 이동 → 아웃덴트 (부모의 형제로) + else if (deltaX < -INDENT_THRESHOLD && oldPid !== null) { + const parentRow = rows.find(r => parseInt(r.dataset.menuId) === oldPid); + if (parentRow) { + const grandParentIdRaw = parentRow.dataset.parentId; + newParentId = grandParentIdRaw === '' ? null : (grandParentIdRaw ? parseInt(grandParentIdRaw) : null); + } else { + newParentId = null; + } + } + + const parentChanged = oldPid !== newParentId; + + if (parentChanged) { + // 새 부모 하위에서의 순서 계산 + const existingSiblings = rows.filter(row => { + if (row === movedItem) return false; + const rowParentId = row.dataset.parentId || ''; + const rowPid = rowParentId === '' ? null : parseInt(rowParentId); + return rowPid === newParentId; + }); + + let siblingsBeforeMe = 0; + for (const sibling of existingSiblings) { + if (rows.indexOf(sibling) < newIndex) { + siblingsBeforeMe++; + } + } + sortOrder = siblingsBeforeMe + 1; + + // 계층 변경 API 호출 + window.moveMenu(menuId, newParentId, sortOrder, csrfToken); + } else { + // 같은 레벨 내 순서 변경 + const siblingRows = rows.filter(row => { + const rowParentId = row.dataset.parentId || ''; + const rowPid = rowParentId === '' ? null : parseInt(rowParentId); + return rowPid === oldPid; + }); + + const items = siblingRows.map((row, index) => ({ + id: parseInt(row.dataset.menuId), + sort_order: index + 1 + })); + + window.saveMenuOrder(items, csrfToken); + } + } + }); +}; + +// 메뉴 이동 API 호출 (계층 변경) +window.moveMenu = function(menuId, newParentId, sortOrder, csrfToken) { + fetch('/api/admin/menus/move', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken, + 'Accept': 'application/json' + }, + body: JSON.stringify({ + menu_id: menuId, + new_parent_id: newParentId, + sort_order: sortOrder + }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + htmx.trigger('#menu-table', 'filterSubmit'); + } else { + showToast('메뉴 이동 실패: ' + (data.message || ''), 'error'); + htmx.trigger('#menu-table', 'filterSubmit'); + } + }) + .catch(error => { + console.error('moveMenu API Error:', error); + showToast('메뉴 이동 중 오류 발생', 'error'); + htmx.trigger('#menu-table', 'filterSubmit'); + }); +}; + +// 메뉴 순서 저장 API 호출 (같은 레벨) +window.saveMenuOrder = function(items, csrfToken) { + fetch('/api/admin/menus/reorder', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken, + 'Accept': 'application/json' + }, + body: JSON.stringify({ items: items }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + htmx.trigger('#menu-table', 'filterSubmit'); + } else { + showToast('순서 변경 실패: ' + (data.message || ''), 'error'); + htmx.trigger('#menu-table', 'filterSubmit'); + } + }) + .catch(error => { + console.error('Error:', error); + showToast('순서 변경 중 오류 발생', 'error'); + htmx.trigger('#menu-table', 'filterSubmit'); + }); +}; \ No newline at end of file diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 1559d82c..b08c2e0d 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -48,32 +48,76 @@ @@ -146,6 +220,9 @@ class="fixed inset-0 bg-black/50 z-40 hidden lg:hidden" + + + -