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:
@@ -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 반환
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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 분석 / 재전송 함수
|
||||
// ==========================================
|
||||
|
||||
@@ -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
|
||||
|
||||
89
resources/views/dev-tools/partials/auth-modal.blade.php
Normal file
89
resources/views/dev-tools/partials/auth-modal.blade.php
Normal 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>
|
||||
389
resources/views/dev-tools/partials/auth-scripts.blade.php
Normal file
389
resources/views/dev-tools/partials/auth-scripts.blade.php
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
// 필요시 테이블 로드 후 초기화 작업
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user