refactor(dev-tools): 인증 시스템 통합 및 테넌트 사용자 조회 개선

## 인증 모달 통합
- api-explorer, flow-tester, api-logs 3개 페이지의 인증 UI 통합
- 공유 컴포넌트 생성: auth-modal.blade.php, auth-scripts.blade.php
- sessionStorage 기반으로 페이지 간 인증 상태 공유
- DevToolsAuth 글로벌 JavaScript API 제공

## 테넌트 사용자 조회 개선
- 시스템 헤더에서 선택한 테넌트의 사용자 목록 표시
- 관리자가 모든 테넌트의 사용자 조회 가능 (소속 무관)
- session('selected_tenant_id')로 Tenant 모델 직접 조회
- 테넌트 미선택 시 안내 메시지 표시

## 버그 수정
- /users 페이지 HTMX swap 오류 수정 (JSON→HTML 직접 반환)
- 사용자 이름 JavaScript 이스케이프 처리 (@js() 사용)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-21 15:13:01 +09:00
parent 0d79aa3d37
commit 1cbaf1b873
12 changed files with 604 additions and 632 deletions

View File

@@ -19,18 +19,18 @@ public function __construct(
/**
* 사용자 목록 조회
*/
public function index(Request $request): JsonResponse
public function index(Request $request): JsonResponse|\Illuminate\Http\Response
{
$users = $this->userService->getUsers(
$request->all(),
$request->integer('per_page', 10)
);
// HTMX 요청인 경우 HTML 반환
// HTMX 요청인 경우 HTML 직접 반환
if ($request->header('HX-Request')) {
$html = view('users.partials.table', compact('users'))->render();
return response()->json(['html' => $html]);
return response($html)->header('Content-Type', 'text/html');
}
// 일반 API 요청인 경우 JSON 반환

View File

@@ -447,31 +447,50 @@ public function setDefaultEnvironment(int $id): JsonResponse
/**
* 현재 테넌트의 사용자 목록
* 시스템 헤더에서 선택한 테넌트 기준 (session('selected_tenant_id'))
* 관리자는 자신이 속하지 않은 테넌트의 사용자도 볼 수 있어야 함
*/
public function users(): JsonResponse
{
// user_tenants 피벗 테이블에서 기본 테넌트 조회
$defaultTenant = \DB::table('user_tenants')
->where('user_id', auth()->id())
->where('is_default', true)
->first();
// 세션에서 직접 테넌트 ID 조회 (관리자가 선택한 테넌트)
$selectedTenantId = session('selected_tenant_id');
if (!$defaultTenant) {
if (!$selectedTenantId) {
// 테넌트가 선택되지 않은 경우 로그인 사용자의 기본 테넌트 사용
$currentTenant = auth()->user()->tenants()
->where('is_default', true)
->first() ?? auth()->user()->tenants()->first();
if (!$currentTenant) {
return response()->json([]);
}
$selectedTenantId = $currentTenant->id;
}
// Tenant 모델에서 직접 조회 (사용자의 테넌트 관계와 무관하게)
$tenant = \App\Models\Tenants\Tenant::find($selectedTenantId);
if (!$tenant) {
return response()->json([]);
}
$tenantId = $defaultTenant->tenant_id;
// 해당 테넌트에 속한 사용자 목록 조회
$users = \App\Models\User::whereHas('tenants', function ($query) use ($tenantId) {
$query->where('tenant_id', $tenantId);
$users = \App\Models\User::whereHas('tenants', function ($query) use ($selectedTenantId) {
$query->where('tenant_id', $selectedTenantId);
})
->select(['id', 'name', 'email'])
->orderBy('name')
->limit(100)
->get();
return response()->json($users);
return response()->json([
'tenant' => [
'id' => $tenant->id,
'name' => $tenant->company_name,
],
'users' => $users,
]);
}
/**

View File

@@ -21,7 +21,7 @@ class FlowTesterController extends Controller
*/
public function index(): View
{
$flows = AdminApiFlow::with(['runs' => fn ($q) => $q->latest()->limit(1)])
$flows = AdminApiFlow::with('latestRun')
->orderByDesc('created_at')
->paginate(20);

View File

@@ -6,6 +6,7 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
/**
* API Flow Tester - 플로우 정의 모델
@@ -83,9 +84,22 @@ public function updater(): BelongsTo
}
/**
* 최근 실행 결과 조회
* 관계: 최신 실행 기록 (서브쿼리 방식 - window function 회피)
*/
public function latestRun(): ?AdminApiFlowRun
public function latestRun(): HasOne
{
return $this->hasOne(AdminApiFlowRun::class, 'flow_id')
->whereIn('id', function ($query) {
$query->selectRaw('MAX(id)')
->from('admin_api_flow_runs')
->groupBy('flow_id');
});
}
/**
* 최근 실행 결과 조회 (단일 조회용)
*/
public function getLatestRunResult(): ?AdminApiFlowRun
{
return $this->runs()->latest('created_at')->first();
}

View File

@@ -7,6 +7,13 @@
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">API 요청 로그</h1>
<div class="flex gap-2">
<!-- 인증 상태 버튼 -->
<button onclick="DevToolsAuth.openModal()" class="flex items-center gap-2 bg-white border border-gray-300 hover:bg-gray-50 px-4 py-2 rounded-lg transition">
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
</svg>
<span class="dev-tools-auth-status text-gray-500">인증 필요</span>
</button>
<form id="pruneForm" action="{{ route('dev-tools.api-logs.prune') }}" method="POST" class="inline">
@csrf
<button type="button" onclick="showConfirmModal('prune', '오래된 로그 삭제', '하루 지난 로그를 삭제하시겠습니까?', 'yellow')" class="bg-yellow-600 hover:bg-yellow-700 text-white px-4 py-2 rounded-lg transition">
@@ -429,45 +436,15 @@ function showAlertModal(title, message, color = 'blue') {
</div>
</div>
<!-- 인증 방식 선택 -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">인증 방식</label>
<div class="flex gap-4">
<label class="flex items-center">
<input type="radio" name="resendAuthType" value="token" checked onchange="toggleResendAuthType()" class="mr-2">
<span class="text-sm">토큰 직접 입력</span>
</label>
<label class="flex items-center">
<input type="radio" name="resendAuthType" value="user" onchange="toggleResendAuthType()" class="mr-2">
<span class="text-sm">사용자 선택</span>
</label>
</div>
</div>
<!-- 토큰 직접 입력 -->
<div id="resendTokenSection" class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Bearer 토큰</label>
<input type="text" id="resendBearerToken" placeholder="Bearer 토큰을 입력하세요"
class="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
value="{{ $savedToken ?? '' }}">
@if($savedToken ?? false)
<p class="mt-1 text-xs text-green-600"> 세션에 저장된 토큰이 자동으로 입력되었습니다.</p>
@endif
</div>
<!-- 사용자 선택 -->
<div id="resendUserSection" class="mb-4 hidden">
<label class="block text-sm font-medium text-gray-700 mb-1">테넌트 사용자 선택</label>
<select id="resendSelectedUser" class="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="">사용자를 선택하세요</option>
</select>
<div id="resendUserSpinner" class="hidden mt-2 text-sm text-gray-500">
<svg class="animate-spin h-4 w-4 inline mr-1" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
사용자 목록 로딩 ...
<!-- 인증 상태 표시 -->
<div class="mb-4 p-3 bg-gray-50 rounded-lg flex items-center justify-between">
<div class="text-sm text-gray-600">
<span class="font-medium">인증 상태:</span>
<span id="resendAuthStatus" class="ml-1 text-gray-500">인증 필요</span>
</div>
<button type="button" onclick="DevToolsAuth.openModal()" class="text-blue-600 hover:text-blue-700 text-sm">
인증 설정
</button>
</div>
<!-- 결과 표시 영역 -->
@@ -502,9 +479,6 @@ class="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-5
<script>
// 재전송 모달 관련 변수
let currentLogId = null;
let currentTenantId = null;
let resendUsersLoaded = {};
let savedBearerToken = '{{ $savedToken ?? '' }}'; // 세션 토큰 또는 발급된 토큰
// 메서드별 색상
const methodColors = {
@@ -515,9 +489,19 @@ class="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-5
'DELETE': 'bg-red-100 text-red-800'
};
// 재전송 모달 인증 상태 업데이트
function updateResendAuthStatus() {
const statusEl = document.getElementById('resendAuthStatus');
if (statusEl && typeof DevToolsAuth !== 'undefined') {
const isAuth = DevToolsAuth.isAuthenticated();
statusEl.textContent = isAuth ? '인증됨' : '인증 필요';
statusEl.classList.toggle('text-green-600', isAuth);
statusEl.classList.toggle('text-gray-500', !isAuth);
}
}
function openResendModal(logId, method, url, tenantId) {
currentLogId = logId;
currentTenantId = tenantId;
// 요청 정보 표시
const methodSpan = document.getElementById('resendMethod');
@@ -527,10 +511,9 @@ function openResendModal(logId, method, url, tenantId) {
// 초기화
document.getElementById('resendResult').classList.add('hidden');
// 저장된 토큰이 있으면 자동 채우기
document.getElementById('resendBearerToken').value = savedBearerToken || '';
document.querySelector('input[name="resendAuthType"][value="token"]').checked = true;
toggleResendAuthType();
// 인증 상태 업데이트
updateResendAuthStatus();
document.getElementById('resendModal').classList.remove('hidden');
}
@@ -540,79 +523,21 @@ function closeResendModal() {
currentLogId = null;
}
function toggleResendAuthType() {
const authType = document.querySelector('input[name="resendAuthType"]:checked').value;
const tokenSection = document.getElementById('resendTokenSection');
const userSection = document.getElementById('resendUserSection');
if (authType === 'token') {
tokenSection.classList.remove('hidden');
userSection.classList.add('hidden');
} else {
tokenSection.classList.add('hidden');
userSection.classList.remove('hidden');
loadResendUsers();
}
}
function loadResendUsers() {
if (resendUsersLoaded[currentTenantId]) return;
const spinner = document.getElementById('resendUserSpinner');
const select = document.getElementById('resendSelectedUser');
spinner.classList.remove('hidden');
fetch(`/api/admin/users?tenant_id=${currentTenantId}&per_page=100`, {
headers: {
'Accept': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
}
})
.then(response => response.json())
.then(data => {
spinner.classList.add('hidden');
select.innerHTML = '<option value="">사용자를 선택하세요</option>';
const users = data.data || data;
users.forEach(user => {
const option = document.createElement('option');
option.value = user.id;
option.textContent = `${user.name || user.email} (${user.email})`;
select.appendChild(option);
});
resendUsersLoaded[currentTenantId] = true;
})
.catch(err => {
spinner.classList.add('hidden');
console.error('사용자 목록 로드 실패:', err);
});
}
function executeResend() {
if (!currentLogId) return;
// 인증 체크
if (!DevToolsAuth.isAuthenticated()) {
showAlertModal('인증 필요', '먼저 인증 설정을 해주세요.', 'yellow');
return;
}
const btn = document.getElementById('resendBtn');
const resultDiv = document.getElementById('resendResult');
const resultContent = document.getElementById('resendResultContent').querySelector('pre');
const authType = document.querySelector('input[name="resendAuthType"]:checked').value;
let payload = { log_id: currentLogId };
if (authType === 'token') {
const token = document.getElementById('resendBearerToken').value.trim();
if (!token) {
showAlertModal('입력 필요', 'Bearer 토큰을 입력해주세요.', 'yellow');
return;
}
payload.token = token;
} else {
const userId = document.getElementById('resendSelectedUser').value;
if (!userId) {
showAlertModal('선택 필요', '사용자를 선택해주세요.', 'yellow');
return;
}
payload.user_id = userId;
}
// 공유 인증 페이로드 사용
let payload = { log_id: currentLogId, ...DevToolsAuth.getAuthPayload() };
// 버튼 로딩 상태
btn.disabled = true;
@@ -695,5 +620,20 @@ function executeResend() {
`;
});
}
// 인증 상태 변경 시 재전송 모달 상태도 업데이트
document.addEventListener('DOMContentLoaded', function() {
if (typeof DevToolsAuth !== 'undefined') {
DevToolsAuth.onAuthChange(function() {
updateResendAuthStatus();
});
}
});
</script>
<!-- 공유 인증 모달 -->
@include('dev-tools.partials.auth-modal')
<!-- 공유 인증 스크립트 -->
@include('dev-tools.partials.auth-scripts')
@endsection

View File

@@ -74,7 +74,7 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:rin
<div class="mb-6">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="remember" class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-2 focus:ring-primary" />
<input type="checkbox" name="remember" value="1" class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-2 focus:ring-primary" />
<span class="text-sm text-gray-700">로그인 상태 유지</span>
</label>
</div>

View File

@@ -197,13 +197,11 @@
</select>
<!-- 인증 버튼 -->
<button onclick="openAuthModal()" class="px-3 py-2 border border-gray-300 rounded-lg text-sm hover:bg-gray-50 flex items-center gap-2" title="인증 설정">
<button onclick="DevToolsAuth.openModal()" class="px-3 py-2 border border-gray-300 rounded-lg text-sm hover:bg-gray-50 flex items-center gap-2" title="인증 설정">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
<span id="auth-status" class="{{ $savedToken ? 'text-green-600' : 'text-gray-500' }}">
{{ $savedToken ? '인증됨' : '인증 필요' }}
</span>
<span id="auth-status" class="dev-tools-auth-status text-gray-500">인증 필요</span>
</button>
<!-- 히스토리 버튼 -->
@@ -296,88 +294,8 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-
</div>
</div>
<!-- 인증 모달 -->
<div id="authModal" class="fixed inset-0 z-50 hidden">
<div class="fixed inset-0 bg-black/50" onclick="closeAuthModal()"></div>
<div class="fixed inset-0 flex items-center justify-center p-4">
<div class="bg-white rounded-lg shadow-xl max-w-lg w-full p-6 relative">
<!-- 헤더 -->
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900">인증 설정</h3>
<button onclick="closeAuthModal()" class="text-gray-400 hover:text-gray-600">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- 인증 방식 선택 -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">인증 방식</label>
<div class="flex gap-4">
<label class="flex items-center">
<input type="radio" name="authType" value="token" checked onchange="toggleAuthType()" class="mr-2">
<span class="text-sm">토큰 직접 입력</span>
</label>
<label class="flex items-center">
<input type="radio" name="authType" value="user" onchange="toggleAuthType()" class="mr-2">
<span class="text-sm">사용자 선택</span>
</label>
</div>
</div>
<!-- 토큰 직접 입력 섹션 -->
<div id="authTokenSection" class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Bearer 토큰</label>
<input type="text" id="authBearerToken" placeholder="Bearer 토큰을 입력하세요"
class="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
value="{{ $savedToken ?? '' }}">
@if($savedToken)
<p class="mt-1 text-xs text-green-600"> 세션에 저장된 토큰이 있습니다.</p>
@endif
</div>
<!-- 사용자 선택 섹션 -->
<div id="authUserSection" class="mb-4 hidden">
<label class="block text-sm font-medium text-gray-700 mb-1">테넌트 사용자 선택</label>
<select id="authSelectedUser" class="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="">사용자를 선택하세요</option>
</select>
<div id="authUserSpinner" class="hidden mt-2 text-sm text-gray-500">
<svg class="animate-spin h-4 w-4 inline mr-1" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
사용자 목록 로딩 ...
</div>
<p class="mt-1 text-xs text-gray-500">선택된 사용자로 Sanctum 토큰이 자동 발급됩니다.</p>
</div>
<!-- 현재 인증 상태 -->
<div id="authCurrentStatus" class="mb-4 p-3 bg-gray-50 rounded-lg">
<div class="text-sm text-gray-600">
<span class="font-medium">현재 상태:</span>
<span id="authStatusDisplay" class="{{ $savedToken ? 'text-green-600' : 'text-gray-500' }}">
{{ $savedToken ? '인증됨' : '인증 필요' }}
</span>
</div>
</div>
<!-- 버튼 -->
<div class="flex justify-end gap-3">
<button type="button" onclick="clearAuth()" class="px-4 py-2 text-red-600 bg-red-50 hover:bg-red-100 rounded-lg transition">
인증 초기화
</button>
<button type="button" onclick="closeAuthModal()" class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition">
닫기
</button>
<button type="button" onclick="saveAuth()" class="px-4 py-2 text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition">
적용
</button>
</div>
</div>
</div>
</div>
<!-- 공유 인증 모달 -->
@include('dev-tools.partials.auth-modal')
<!-- 히스토리 서랍 (오버레이) -->
<div id="history-drawer" class="fixed inset-y-0 right-0 w-96 bg-white shadow-xl transform translate-x-full transition-transform duration-300 z-50">
@@ -402,6 +320,9 @@ class="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-5
@endsection
@push('scripts')
{{-- 공유 인증 스크립트 --}}
@include('dev-tools.partials.auth-scripts')
<script>
// 전체 엔드포인트 데이터 (클라이언트 사이드 필터링용)
const allEndpoints = @json($endpoints->flatten(1)->values());
@@ -837,13 +758,8 @@ function selectEndpointByPath(path, method, element) {
submitBtn.innerHTML = '<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> 실행 중...';
submitBtn.disabled = true;
// 인증 정보 구성
const authPayload = {};
if (currentAuthToken) {
authPayload.token = currentAuthToken;
} else if (currentAuthUserId) {
authPayload.user_id = currentAuthUserId;
}
// 인증 정보 구성 (공유 인증 모듈 사용)
const authPayload = DevToolsAuth.getAuthPayload();
// 마지막 요청 데이터 저장 (재전송, AI 분석용)
lastRequestData = {
@@ -1235,150 +1151,6 @@ function openSettingsModal() {
showToast('환경 설정 기능은 Phase 2에서 구현 예정입니다.', 'info');
}
// ==========================================
// 인증 관련 함수
// ==========================================
// 인증 상태 변수
let currentAuthToken = '{{ $savedToken ?? '' }}';
let currentAuthUserId = null;
let authUsersLoaded = false;
// 인증 모달 열기
function openAuthModal() {
document.getElementById('authModal').classList.remove('hidden');
// 현재 상태 반영
document.getElementById('authBearerToken').value = currentAuthToken || '';
document.querySelector('input[name="authType"][value="token"]').checked = true;
toggleAuthType();
}
// 인증 모달 닫기
function closeAuthModal() {
document.getElementById('authModal').classList.add('hidden');
}
// 인증 방식 토글
function toggleAuthType() {
const authType = document.querySelector('input[name="authType"]:checked').value;
const tokenSection = document.getElementById('authTokenSection');
const userSection = document.getElementById('authUserSection');
if (authType === 'token') {
tokenSection.classList.remove('hidden');
userSection.classList.add('hidden');
} else {
tokenSection.classList.add('hidden');
userSection.classList.remove('hidden');
loadAuthUsers();
}
}
// 사용자 목록 로드
function loadAuthUsers() {
if (authUsersLoaded) return;
const spinner = document.getElementById('authUserSpinner');
const select = document.getElementById('authSelectedUser');
spinner.classList.remove('hidden');
fetch('{{ route("dev-tools.api-explorer.users") }}', {
headers: {
'Accept': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
})
.then(response => response.json())
.then(data => {
spinner.classList.add('hidden');
select.innerHTML = '<option value="">사용자를 선택하세요</option>';
const users = data.data || data;
users.forEach(user => {
const option = document.createElement('option');
option.value = user.id;
option.textContent = `${user.name || user.email} (${user.email})`;
select.appendChild(option);
});
authUsersLoaded = true;
})
.catch(err => {
spinner.classList.add('hidden');
console.error('사용자 목록 로드 실패:', err);
showToast('사용자 목록을 불러오는데 실패했습니다.', 'error');
});
}
// 인증 저장 (적용 버튼)
function saveAuth() {
const authType = document.querySelector('input[name="authType"]:checked').value;
if (authType === 'token') {
const token = document.getElementById('authBearerToken').value.trim();
if (token) {
currentAuthToken = token;
currentAuthUserId = null;
updateAuthStatus(true);
showToast('토큰이 적용되었습니다.', 'success');
} else {
showToast('토큰을 입력해주세요.', 'warning');
return;
}
} else {
const userId = document.getElementById('authSelectedUser').value;
if (userId) {
currentAuthUserId = userId;
currentAuthToken = null; // 사용자 선택시 토큰은 서버에서 발급
updateAuthStatus(true);
showToast('사용자가 선택되었습니다. API 실행 시 토큰이 자동 발급됩니다.', 'success');
} else {
showToast('사용자를 선택해주세요.', 'warning');
return;
}
}
closeAuthModal();
}
// 인증 초기화
function clearAuth() {
currentAuthToken = null;
currentAuthUserId = null;
document.getElementById('authBearerToken').value = '';
document.getElementById('authSelectedUser').value = '';
updateAuthStatus(false);
showToast('인증이 초기화되었습니다.', 'info');
}
// 인증 상태 UI 업데이트
function updateAuthStatus(isAuthenticated) {
const statusEl = document.getElementById('auth-status');
const modalStatusEl = document.getElementById('authStatusDisplay');
if (isAuthenticated) {
statusEl.textContent = '인증됨';
statusEl.classList.remove('text-gray-500');
statusEl.classList.add('text-green-600');
if (modalStatusEl) {
modalStatusEl.textContent = '인증됨';
modalStatusEl.classList.remove('text-gray-500');
modalStatusEl.classList.add('text-green-600');
}
} else {
statusEl.textContent = '인증 필요';
statusEl.classList.remove('text-green-600');
statusEl.classList.add('text-gray-500');
if (modalStatusEl) {
modalStatusEl.textContent = '인증 필요';
modalStatusEl.classList.remove('text-green-600');
modalStatusEl.classList.add('text-gray-500');
}
}
}
// ==========================================
// AI 분석 / 재전송 함수
// ==========================================

View File

@@ -8,19 +8,11 @@
<h1 class="text-2xl font-bold text-gray-800">API 플로우 테스터</h1>
<div class="flex gap-2">
<!-- 인증 버튼 -->
<button onclick="openAuthModal()" class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 flex items-center gap-2" title="인증 설정">
<button onclick="DevToolsAuth.openModal()" class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 flex items-center gap-2" title="인증 설정">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
<span id="auth-status" class="{{ $savedToken ? 'text-green-600' : 'text-gray-500' }}">
@if($selectedUser ?? null)
{{ $selectedUser->name }}
@elseif($savedToken)
인증됨
@else
인증 필요
@endif
</span>
<span id="auth-status" class="dev-tools-auth-status text-gray-500">인증 필요</span>
</button>
<!-- JSON 작성 가이드 버튼 -->
<button onclick="GuideModal.open()"
@@ -106,7 +98,7 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
<tbody class="divide-y divide-gray-200">
@foreach($flows as $flow)
@php
$latestRun = $flow->runs->first();
$latestRun = $flow->latestRun;
$steps = $flow->flow_definition['steps'] ?? [];
@endphp
{{-- 메인 row --}}
@@ -439,102 +431,14 @@ class="p-2 text-red-600 hover:bg-red-50 rounded-lg transition"
@include('dev-tools.flow-tester.partials.guide-modal')
@include('dev-tools.flow-tester.partials.example-flows')
<!-- 인증 모달 -->
<div id="authModal" class="fixed inset-0 z-50 hidden">
<div class="fixed inset-0 bg-black/50" onclick="closeAuthModal()"></div>
<div class="fixed inset-0 flex items-center justify-center p-4">
<div class="bg-white rounded-lg shadow-xl max-w-lg w-full p-6 relative">
<!-- 헤더 -->
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900">인증 설정</h3>
<button onclick="closeAuthModal()" class="text-gray-400 hover:text-gray-600">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- API 서버 로그인 -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">
API 서버 로그인
<span class="text-xs text-gray-500 font-normal ml-1">(API Explorer와 공유)</span>
</label>
<div class="space-y-2">
<input type="text" id="authUserId" placeholder="사용자 ID"
class="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<input type="password" id="authUserPwd" placeholder="비밀번호"
class="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<button type="button" onclick="loginToApi()" class="w-full px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition text-sm">
API 서버 로그인
</button>
</div>
<p class="mt-1 text-xs text-gray-500">API 서버에 직접 로그인하여 토큰을 발급받습니다.</p>
</div>
<!-- 구분선 -->
<div class="relative my-4">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-200"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 bg-white text-gray-500">또는</span>
</div>
</div>
<!-- 토큰 직접 입력 -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Bearer 토큰 직접 입력</label>
<input type="text" id="authBearerToken" placeholder="Bearer 토큰을 입력하세요"
class="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
value="{{ $savedToken ?? '' }}">
<p class="mt-1 text-xs text-gray-500">플로우에서 <code class="bg-gray-100 px-1 rounded">@{{$session.token}}</code> 참조할 있습니다.</p>
</div>
<!-- 현재 인증 상태 -->
<div id="authCurrentStatus" class="mb-4 p-3 bg-gray-50 rounded-lg">
<div class="text-sm text-gray-600">
<span class="font-medium">현재 상태:</span>
<span id="authStatusDisplay" class="{{ $savedToken ? 'text-green-600' : 'text-gray-500' }}">
@if($selectedUser ?? null)
{{ $selectedUser->name }} ({{ $selectedUser->email }})
@elseif($savedToken)
인증됨
@else
인증 필요
@endif
</span>
</div>
@if($selectedUser ?? null)
<div class="text-xs text-gray-500 mt-1">
tenant_id: {{ session('selected_tenant_id', '-') }}, user_id: {{ $selectedUser->id }}
</div>
@endif
@if($savedToken)
<div class="text-xs text-gray-500 mt-1 font-mono">
토큰: {{ Str::limit($savedToken, 30, '...') }}
</div>
@endif
</div>
<!-- 버튼 -->
<div class="flex justify-end gap-3">
<button type="button" onclick="clearAuth()" class="px-4 py-2 text-red-600 bg-red-50 hover:bg-red-100 rounded-lg transition">
인증 초기화
</button>
<button type="button" onclick="closeAuthModal()" class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition">
닫기
</button>
<button type="button" onclick="saveAuth()" class="px-4 py-2 text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition">
토큰 저장
</button>
</div>
</div>
</div>
</div>
<!-- 공유 인증 모달 -->
@include('dev-tools.partials.auth-modal')
@endsection
@push('scripts')
{{-- 공유 인증 스크립트 --}}
@include('dev-tools.partials.auth-scripts')
<script>
// 플로우 스텝 상세 토글
function toggleFlowDetail(id, event) {
@@ -768,157 +672,5 @@ function confirmDelete(id, name) {
});
}
/*
|--------------------------------------------------------------------------
| 인증 관리
|--------------------------------------------------------------------------
*/
// 현재 토큰 상태
let currentAuthToken = @json($savedToken ?? '');
function openAuthModal() {
document.getElementById('authModal').classList.remove('hidden');
document.getElementById('authBearerToken').value = currentAuthToken || '';
}
function closeAuthModal() {
document.getElementById('authModal').classList.add('hidden');
}
// API 서버 로그인
function loginToApi() {
const userId = document.getElementById('authUserId').value.trim();
const userPwd = document.getElementById('authUserPwd').value;
if (!userId || !userPwd) {
showToast('사용자 ID와 비밀번호를 입력해주세요.', 'warning');
return;
}
// 버튼 로딩 상태
const btn = event.target;
const originalText = btn.textContent;
btn.disabled = true;
btn.textContent = '로그인 중...';
fetch('{{ route("dev-tools.flow-tester.login-to-api") }}', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ user_id: userId, user_pwd: userPwd }),
})
.then(response => response.json())
.then(data => {
btn.disabled = false;
btn.textContent = originalText;
if (data.success) {
showToast(data.message, 'success');
setTimeout(() => {
location.reload();
}, 500);
} else {
showToast(data.message || 'API 로그인 실패', 'error');
}
})
.catch(error => {
btn.disabled = false;
btn.textContent = originalText;
showToast('오류 발생: ' + error.message, 'error');
});
}
function saveAuth() {
const token = document.getElementById('authBearerToken').value.trim();
if (!token) {
showToast('토큰을 입력해주세요.', 'warning');
return;
}
// 서버에 토큰 저장
fetch('{{ route("dev-tools.flow-tester.token.save") }}', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ token: token }),
})
.then(response => response.json())
.then(data => {
if (data.success) {
currentAuthToken = token;
updateAuthStatus(true, null);
showToast('토큰이 저장되었습니다.', 'success');
closeAuthModal();
} else {
showToast(data.message || '저장 실패', 'error');
}
})
.catch(error => {
showToast('오류 발생: ' + error.message, 'error');
});
}
function clearAuth() {
// 서버에서 토큰 삭제
fetch('{{ route("dev-tools.flow-tester.token.clear") }}', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json',
},
})
.then(response => response.json())
.then(data => {
if (data.success) {
currentAuthToken = '';
document.getElementById('authBearerToken').value = '';
updateAuthStatus(false, null);
showToast('인증이 초기화되었습니다.', 'info');
setTimeout(() => {
location.reload();
}, 500);
} else {
showToast(data.message || '초기화 실패', 'error');
}
})
.catch(error => {
showToast('오류 발생: ' + error.message, 'error');
});
}
function updateAuthStatus(isAuthenticated, userName) {
const statusEl = document.getElementById('auth-status');
const modalStatusEl = document.getElementById('authStatusDisplay');
if (isAuthenticated) {
statusEl.textContent = userName || '인증됨';
statusEl.classList.remove('text-gray-500');
statusEl.classList.add('text-green-600');
if (modalStatusEl) {
modalStatusEl.textContent = userName ? `✅ ${userName}` : '인증됨';
modalStatusEl.classList.remove('text-gray-500');
modalStatusEl.classList.add('text-green-600');
}
} else {
statusEl.textContent = '인증 필요';
statusEl.classList.remove('text-green-600');
statusEl.classList.add('text-gray-500');
if (modalStatusEl) {
modalStatusEl.textContent = '인증 필요';
modalStatusEl.classList.remove('text-green-600');
modalStatusEl.classList.add('text-gray-500');
}
}
}
</script>
@endpush

View File

@@ -0,0 +1,89 @@
{{--
Dev Tools 공유 인증 모달
사용법: @include('dev-tools.partials.auth-modal')
--}}
<!-- 인증 모달 -->
<div id="devToolsAuthModal" class="fixed inset-0 z-50 hidden">
<div class="fixed inset-0 bg-black/50" onclick="DevToolsAuth.closeModal()"></div>
<div class="fixed inset-0 flex items-center justify-center p-4">
<div class="bg-white rounded-lg shadow-xl max-w-lg w-full p-6 relative">
<!-- 헤더 -->
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900">인증 설정</h3>
<button onclick="DevToolsAuth.closeModal()" class="text-gray-400 hover:text-gray-600">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- 인증 방식 선택 -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">인증 방식</label>
<div class="flex gap-4">
<label class="flex items-center">
<input type="radio" name="devToolsAuthType" value="token" checked onchange="DevToolsAuth.toggleType()" class="mr-2">
<span class="text-sm">토큰 직접 입력</span>
</label>
<label class="flex items-center">
<input type="radio" name="devToolsAuthType" value="user" onchange="DevToolsAuth.toggleType()" class="mr-2">
<span class="text-sm">사용자 선택</span>
</label>
</div>
</div>
<!-- 토큰 직접 입력 섹션 -->
<div id="devToolsAuthTokenSection" class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Bearer 토큰</label>
<input type="text" id="devToolsAuthBearerToken" placeholder="Bearer 토큰을 입력하세요"
class="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<!-- 사용자 선택 섹션 -->
<div id="devToolsAuthUserSection" class="mb-4 hidden">
<!-- 현재 테넌트 표시 -->
<div id="devToolsAuthTenantLabel" class="hidden mb-2 px-3 py-2 bg-blue-50 border border-blue-200 rounded-lg text-sm text-blue-700">
테넌트: 로딩 ...
</div>
<label class="block text-sm font-medium text-gray-700 mb-1">사용자 선택</label>
<select id="devToolsAuthSelectedUser" class="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="">사용자를 선택하세요</option>
</select>
<div id="devToolsAuthUserSpinner" class="hidden mt-2 text-sm text-gray-500">
<svg class="animate-spin h-4 w-4 inline mr-1" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
사용자 목록 로딩 ...
</div>
<p class="mt-1 text-xs text-gray-500">
시스템 헤더에서 선택한 테넌트의 사용자 목록입니다.<br>
선택된 사용자로 Sanctum 토큰이 자동 발급됩니다.
</p>
</div>
<!-- 현재 인증 상태 -->
<div id="devToolsAuthCurrentStatus" class="mb-4 p-3 bg-gray-50 rounded-lg">
<div class="text-sm text-gray-600">
<span class="font-medium">현재 상태:</span>
<span id="devToolsAuthStatusDisplay" class="text-gray-500">인증 필요</span>
</div>
<div id="devToolsAuthUserInfo" class="mt-1 text-xs text-gray-500 hidden"></div>
</div>
<!-- 버튼 -->
<div class="flex justify-end gap-3">
<button type="button" onclick="DevToolsAuth.clear()" class="px-4 py-2 text-red-600 bg-red-50 hover:bg-red-100 rounded-lg transition">
인증 초기화
</button>
<button type="button" onclick="DevToolsAuth.closeModal()" class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition">
닫기
</button>
<button type="button" onclick="DevToolsAuth.save()" class="px-4 py-2 text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition">
적용
</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,389 @@
{{--
Dev Tools 공유 인증 스크립트
사용법: @include('dev-tools.partials.auth-scripts')
제공 API:
- DevToolsAuth.openModal() : 인증 모달 열기
- DevToolsAuth.closeModal() : 인증 모달 닫기
- DevToolsAuth.getToken() : 현재 토큰 반환
- DevToolsAuth.getUserId() : 현재 사용자 ID 반환
- DevToolsAuth.isAuthenticated() : 인증 여부 반환
- DevToolsAuth.getAuthPayload() : API 요청용 인증 페이로드 반환
- DevToolsAuth.onAuthChange(callback) : 인증 상태 변경 콜백 등록
--}}
<script>
(function() {
'use strict';
const STORAGE_KEY = 'devToolsAuth';
const USERS_ENDPOINT = '{{ route("dev-tools.api-explorer.users") }}';
const CSRF_TOKEN = '{{ csrf_token() }}';
// 인증 상태
let state = {
token: null,
userId: null,
userName: null,
userEmail: null
};
// 사용자 목록 캐시
let usersLoaded = false;
let usersCache = [];
let currentTenantInfo = null;
// 상태 변경 콜백 목록
let changeCallbacks = [];
// sessionStorage에서 상태 로드
function loadState() {
try {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
state = JSON.parse(saved);
}
} catch (e) {
console.warn('Failed to load auth state:', e);
}
}
// sessionStorage에 상태 저장
function saveState() {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (e) {
console.warn('Failed to save auth state:', e);
}
}
// UI 업데이트
function updateUI() {
const isAuth = !!(state.token || state.userId);
// 헤더의 인증 상태 버튼 업데이트 (id="auth-status" 또는 class="dev-tools-auth-status")
document.querySelectorAll('#auth-status, .dev-tools-auth-status').forEach(el => {
el.textContent = isAuth ? '인증됨' : '인증 필요';
el.classList.toggle('text-green-600', isAuth);
el.classList.toggle('text-gray-500', !isAuth);
});
// 모달 내 상태 표시
const modalStatus = document.getElementById('devToolsAuthStatusDisplay');
if (modalStatus) {
modalStatus.textContent = isAuth ? '인증됨' : '인증 필요';
modalStatus.classList.toggle('text-green-600', isAuth);
modalStatus.classList.toggle('text-gray-500', !isAuth);
}
// 사용자 정보 표시
const userInfo = document.getElementById('devToolsAuthUserInfo');
if (userInfo) {
if (state.userId && state.userName) {
userInfo.textContent = `사용자: ${state.userName} (${state.userEmail})`;
userInfo.classList.remove('hidden');
} else if (state.token) {
userInfo.textContent = '토큰으로 인증됨';
userInfo.classList.remove('hidden');
} else {
userInfo.classList.add('hidden');
}
}
// 토큰 입력 필드 업데이트
const tokenInput = document.getElementById('devToolsAuthBearerToken');
if (tokenInput && state.token) {
tokenInput.value = state.token;
}
// 사용자 선택 업데이트
const userSelect = document.getElementById('devToolsAuthSelectedUser');
if (userSelect && state.userId) {
userSelect.value = state.userId;
}
}
// 콜백 호출
function notifyChange() {
const isAuth = !!(state.token || state.userId);
changeCallbacks.forEach(cb => {
try {
cb(isAuth, state);
} catch (e) {
console.error('Auth change callback error:', e);
}
});
}
// 사용자 목록 로드
async function loadUsers() {
if (usersLoaded) return usersCache;
const spinner = document.getElementById('devToolsAuthUserSpinner');
const select = document.getElementById('devToolsAuthSelectedUser');
const tenantLabel = document.getElementById('devToolsAuthTenantLabel');
if (spinner) spinner.classList.remove('hidden');
try {
const response = await fetch(USERS_ENDPOINT, {
headers: {
'Accept': 'application/json',
'X-CSRF-TOKEN': CSRF_TOKEN
}
});
if (!response.ok) throw new Error('Failed to load users');
const data = await response.json();
// 새 응답 형식: { tenant: {...}, users: [...] }
if (data.tenant) {
currentTenantInfo = data.tenant;
usersCache = data.users || [];
} else if (Array.isArray(data) && data.length === 0) {
// 빈 배열 응답 - 테넌트 미선택
currentTenantInfo = null;
usersCache = [];
} else {
// 기존 형식 호환성 유지
usersCache = Array.isArray(data) ? data : (data.data || []);
}
usersLoaded = true;
// 테넌트 정보 표시
if (tenantLabel) {
if (currentTenantInfo) {
tenantLabel.textContent = `테넌트: ${currentTenantInfo.name}`;
tenantLabel.classList.remove('hidden');
tenantLabel.className = 'mb-2 px-3 py-2 bg-blue-50 border border-blue-200 rounded-lg text-sm text-blue-700';
} else {
tenantLabel.textContent = '시스템 헤더에서 테넌트를 먼저 선택해주세요.';
tenantLabel.classList.remove('hidden');
tenantLabel.className = 'mb-2 px-3 py-2 bg-yellow-50 border border-yellow-200 rounded-lg text-sm text-yellow-700';
}
}
if (select) {
select.innerHTML = '<option value="">사용자를 선택하세요</option>';
if (usersCache.length === 0 && currentTenantInfo) {
select.innerHTML = '<option value="">이 테넌트에 사용자가 없습니다</option>';
}
usersCache.forEach(user => {
const option = document.createElement('option');
option.value = user.id;
option.textContent = `${user.name || user.email} (${user.email})`;
option.dataset.name = user.name || user.email;
option.dataset.email = user.email;
select.appendChild(option);
});
// 현재 선택된 사용자 복원
if (state.userId) {
select.value = state.userId;
}
}
return usersCache;
} catch (err) {
console.error('사용자 목록 로드 실패:', err);
if (typeof showToast === 'function') {
showToast('사용자 목록을 불러오는데 실패했습니다.', 'error');
}
return [];
} finally {
if (spinner) spinner.classList.add('hidden');
}
}
// 공개 API
window.DevToolsAuth = {
// 모달 열기
openModal() {
const modal = document.getElementById('devToolsAuthModal');
if (modal) {
modal.classList.remove('hidden');
updateUI();
// 인증 방식 라디오 버튼 설정
const tokenRadio = document.querySelector('input[name="devToolsAuthType"][value="token"]');
const userRadio = document.querySelector('input[name="devToolsAuthType"][value="user"]');
if (state.userId && !state.token) {
if (userRadio) userRadio.checked = true;
this.toggleType();
} else {
if (tokenRadio) tokenRadio.checked = true;
this.toggleType();
}
}
},
// 모달 닫기
closeModal() {
const modal = document.getElementById('devToolsAuthModal');
if (modal) {
modal.classList.add('hidden');
}
},
// 인증 방식 토글
toggleType() {
const authType = document.querySelector('input[name="devToolsAuthType"]:checked')?.value || 'token';
const tokenSection = document.getElementById('devToolsAuthTokenSection');
const userSection = document.getElementById('devToolsAuthUserSection');
if (authType === 'token') {
if (tokenSection) tokenSection.classList.remove('hidden');
if (userSection) userSection.classList.add('hidden');
} else {
if (tokenSection) tokenSection.classList.add('hidden');
if (userSection) userSection.classList.remove('hidden');
// 사용자 선택 시 항상 새로 로드 (테넌트 변경 대응)
usersLoaded = false;
loadUsers();
}
},
// 인증 저장
save() {
const authType = document.querySelector('input[name="devToolsAuthType"]:checked')?.value || 'token';
if (authType === 'token') {
const tokenInput = document.getElementById('devToolsAuthBearerToken');
const token = tokenInput?.value?.trim();
if (!token) {
if (typeof showToast === 'function') {
showToast('토큰을 입력해주세요.', 'warning');
}
return;
}
state = {
token: token,
userId: null,
userName: null,
userEmail: null
};
if (typeof showToast === 'function') {
showToast('토큰이 적용되었습니다.', 'success');
}
} else {
const userSelect = document.getElementById('devToolsAuthSelectedUser');
const userId = userSelect?.value;
if (!userId) {
if (typeof showToast === 'function') {
showToast('사용자를 선택해주세요.', 'warning');
}
return;
}
const selectedOption = userSelect.options[userSelect.selectedIndex];
state = {
token: null,
userId: userId,
userName: selectedOption?.dataset?.name || '',
userEmail: selectedOption?.dataset?.email || ''
};
if (typeof showToast === 'function') {
showToast('사용자가 선택되었습니다. API 실행 시 토큰이 자동 발급됩니다.', 'success');
}
}
saveState();
updateUI();
notifyChange();
this.closeModal();
},
// 인증 초기화
clear() {
state = {
token: null,
userId: null,
userName: null,
userEmail: null
};
const tokenInput = document.getElementById('devToolsAuthBearerToken');
const userSelect = document.getElementById('devToolsAuthSelectedUser');
if (tokenInput) tokenInput.value = '';
if (userSelect) userSelect.value = '';
saveState();
updateUI();
notifyChange();
if (typeof showToast === 'function') {
showToast('인증이 초기화되었습니다.', 'info');
}
},
// 현재 토큰 반환
getToken() {
return state.token;
},
// 현재 사용자 ID 반환
getUserId() {
return state.userId;
},
// 인증 여부 반환
isAuthenticated() {
return !!(state.token || state.userId);
},
// API 요청용 인증 페이로드 반환
getAuthPayload() {
if (state.token) {
return { token: state.token };
}
if (state.userId) {
return { user_id: state.userId };
}
return {};
},
// 현재 상태 반환
getState() {
return { ...state };
},
// 인증 상태 변경 콜백 등록
onAuthChange(callback) {
if (typeof callback === 'function') {
changeCallbacks.push(callback);
}
},
// 외부에서 상태 설정 (서버에서 저장된 토큰 로드 시 사용)
setToken(token) {
if (token) {
state.token = token;
state.userId = null;
state.userName = null;
state.userEmail = null;
saveState();
updateUI();
notifyChange();
}
}
};
// 초기화
loadState();
// DOM 로드 후 UI 업데이트
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', updateUI);
} else {
updateUI();
}
})();
</script>

View File

@@ -61,13 +61,10 @@ class="bg-white rounded-lg shadow-sm overflow-hidden">
htmx.trigger('#user-table', 'filterSubmit');
});
// HTMX 응답 처리
// HTMX 응답 후 필요한 초기화 처리
document.body.addEventListener('htmx:afterSwap', function(event) {
if (event.detail.target.id === 'user-table') {
const response = JSON.parse(event.detail.xhr.response);
if (response.html) {
event.detail.target.innerHTML = response.html;
}
// 필요시 테이블 로드 후 초기화 작업
}
});

View File

@@ -99,13 +99,13 @@
@if($user->deleted_at)
<!-- 삭제된 항목 - 복원은 일반관리자도 가능, 영구삭제는 슈퍼관리자만 -->
@if($canModify)
<button onclick="confirmRestore({{ $user->id }}, '{{ $user->name }}')"
<button onclick="confirmRestore({{ $user->id }}, @js($user->name))"
class="text-green-600 hover:text-green-900 mr-3">
복원
</button>
@endif
@if(auth()->user()?->is_super_admin)
<button onclick="confirmForceDelete({{ $user->id }}, '{{ $user->name }}')"
<button onclick="confirmForceDelete({{ $user->id }}, @js($user->name))"
class="text-red-600 hover:text-red-900">
영구삭제
</button>
@@ -115,7 +115,7 @@ class="text-red-600 hover:text-red-900">
@endif
@elseif($canModify)
<!-- 활성 항목 (수정 가능한 경우만) -->
<button onclick="openDevSite({{ $user->id }}, '{{ $user->name }}')"
<button onclick="openDevSite({{ $user->id }}, @js($user->name))"
class="text-emerald-600 hover:text-emerald-900 mr-3"
title="DEV 사이트에 이 사용자로 로그인">
DEV 접속
@@ -125,7 +125,7 @@ class="text-emerald-600 hover:text-emerald-900 mr-3"
class="text-blue-600 hover:text-blue-900 mr-3">
수정
</a>
<button onclick="confirmDelete({{ $user->id }}, '{{ $user->name }}')" class="text-red-600 hover:text-red-900">
<button onclick="confirmDelete({{ $user->id }}, @js($user->name))" class="text-red-600 hover:text-red-900">
삭제
</button>
@else