feat(dev-tools): 인증 모달에 회사 선택 및 토큰 표시 기능 추가
- 인증 모달에 회사(테넌트) 선택 드롭다운 추가 - 헤더의 $globalTenants 재사용 - tenant.switch 라우트와 동기화 - 회사 변경 시 사용자 목록 자동 갱신 - Bearer 토큰 표시 및 복사 기능 추가 - 토큰 발급 API 엔드포인트 추가 (POST /dev-tools/api-explorer/issue-token) - 현재 상태 영역에 토큰 표시 - 클립보드 복사 버튼 (Clipboard API + fallback) - 적용 후 모달 유지하여 토큰 복사 가능 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -493,6 +493,36 @@ public function users(): JsonResponse
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 선택 시 Sanctum 토큰 발급
|
||||
*/
|
||||
public function issueToken(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'user_id' => 'required|integer|exists:users,id',
|
||||
]);
|
||||
|
||||
$user = \App\Models\User::find($validated['user_id']);
|
||||
|
||||
if (!$user) {
|
||||
return response()->json(['error' => '사용자를 찾을 수 없습니다.'], 404);
|
||||
}
|
||||
|
||||
// Sanctum 토큰 발급
|
||||
$token = $user->createToken('api-explorer', ['*'])->plainTextToken;
|
||||
session(['api_explorer_token' => $token]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'token' => $token,
|
||||
'user' => [
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 즐겨찾기 수정
|
||||
*/
|
||||
|
||||
@@ -42,14 +42,30 @@ class="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-5
|
||||
|
||||
<!-- 사용자 선택 섹션 -->
|
||||
<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 class="flex gap-3 mb-2">
|
||||
<!-- 회사 선택 -->
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">회사 선택</label>
|
||||
<select id="devToolsAuthTenantSelect" onchange="DevToolsAuth.switchTenant(this.value)" 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>
|
||||
@if(isset($globalTenants) && $globalTenants->isNotEmpty())
|
||||
@foreach($globalTenants as $tenant)
|
||||
<option value="{{ $tenant->id }}" {{ session('selected_tenant_id') == $tenant->id ? 'selected' : '' }}>
|
||||
{{ $tenant->company_name }}
|
||||
</option>
|
||||
@endforeach
|
||||
@endif
|
||||
</select>
|
||||
</div>
|
||||
<!-- 사용자 선택 -->
|
||||
<div class="flex-1">
|
||||
<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>
|
||||
</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>
|
||||
@@ -58,7 +74,7 @@ class="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-5
|
||||
사용자 목록 로딩 중...
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
시스템 헤더에서 선택한 테넌트의 사용자 목록입니다.<br>
|
||||
회사를 선택하면 해당 회사의 사용자 목록이 표시됩니다.<br>
|
||||
선택된 사용자로 Sanctum 토큰이 자동 발급됩니다.
|
||||
</p>
|
||||
</div>
|
||||
@@ -70,6 +86,21 @@ class="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-5
|
||||
<span id="devToolsAuthStatusDisplay" class="text-gray-500">인증 필요</span>
|
||||
</div>
|
||||
<div id="devToolsAuthUserInfo" class="mt-1 text-xs text-gray-500 hidden"></div>
|
||||
<!-- 토큰 표시 영역 -->
|
||||
<div id="devToolsAuthTokenDisplay" class="mt-2 hidden">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-medium text-gray-600">Bearer Token:</span>
|
||||
<button onclick="DevToolsAuth.copyToken()" class="text-xs text-blue-600 hover:text-blue-800 flex items-center gap-1" title="토큰 복사">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
복사
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-1 p-2 bg-gray-100 rounded border text-xs font-mono text-gray-700 break-all max-h-20 overflow-y-auto" id="devToolsAuthTokenValue">
|
||||
-
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 버튼 -->
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
|
||||
const STORAGE_KEY = 'devToolsAuth';
|
||||
const USERS_ENDPOINT = '{{ route("dev-tools.api-explorer.users") }}';
|
||||
const TENANT_SWITCH_ENDPOINT = '{{ route("tenant.switch") }}';
|
||||
const ISSUE_TOKEN_ENDPOINT = '{{ route("dev-tools.api-explorer.issue-token") }}';
|
||||
const CSRF_TOKEN = '{{ csrf_token() }}';
|
||||
|
||||
// 인증 상태
|
||||
@@ -25,7 +27,8 @@
|
||||
token: null,
|
||||
userId: null,
|
||||
userName: null,
|
||||
userEmail: null
|
||||
userEmail: null,
|
||||
actualToken: null // 실제 발급된 토큰 (복사용)
|
||||
};
|
||||
|
||||
// 사용자 목록 캐시
|
||||
@@ -101,6 +104,19 @@ function updateUI() {
|
||||
if (userSelect && state.userId) {
|
||||
userSelect.value = state.userId;
|
||||
}
|
||||
|
||||
// 토큰 표시 영역 업데이트
|
||||
const tokenDisplay = document.getElementById('devToolsAuthTokenDisplay');
|
||||
const tokenValue = document.getElementById('devToolsAuthTokenValue');
|
||||
if (tokenDisplay && tokenValue) {
|
||||
const displayToken = state.actualToken || state.token;
|
||||
if (displayToken) {
|
||||
tokenValue.textContent = displayToken;
|
||||
tokenDisplay.classList.remove('hidden');
|
||||
} else {
|
||||
tokenDisplay.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 콜백 호출
|
||||
@@ -245,7 +261,7 @@ function notifyChange() {
|
||||
},
|
||||
|
||||
// 인증 저장
|
||||
save() {
|
||||
async save() {
|
||||
const authType = document.querySelector('input[name="devToolsAuthType"]:checked')?.value || 'token';
|
||||
|
||||
if (authType === 'token') {
|
||||
@@ -263,12 +279,18 @@ function notifyChange() {
|
||||
token: token,
|
||||
userId: null,
|
||||
userName: null,
|
||||
userEmail: null
|
||||
userEmail: null,
|
||||
actualToken: token // 직접 입력한 토큰도 actualToken에 저장
|
||||
};
|
||||
|
||||
if (typeof showToast === 'function') {
|
||||
showToast('토큰이 적용되었습니다.', 'success');
|
||||
showToast('토큰이 적용되었습니다. 복사 후 닫기를 눌러주세요.', 'success');
|
||||
}
|
||||
|
||||
saveState();
|
||||
updateUI();
|
||||
notifyChange();
|
||||
// 토큰 표시를 위해 모달을 닫지 않음 - 사용자가 직접 닫기 클릭
|
||||
} else {
|
||||
const userSelect = document.getElementById('devToolsAuthSelectedUser');
|
||||
const userId = userSelect?.value;
|
||||
@@ -282,22 +304,47 @@ function notifyChange() {
|
||||
|
||||
const selectedOption = userSelect.options[userSelect.selectedIndex];
|
||||
|
||||
state = {
|
||||
token: null,
|
||||
userId: userId,
|
||||
userName: selectedOption?.dataset?.name || '',
|
||||
userEmail: selectedOption?.dataset?.email || ''
|
||||
};
|
||||
// 토큰 발급 API 호출
|
||||
try {
|
||||
const response = await fetch(ISSUE_TOKEN_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': CSRF_TOKEN
|
||||
},
|
||||
body: JSON.stringify({ user_id: userId })
|
||||
});
|
||||
|
||||
if (typeof showToast === 'function') {
|
||||
showToast('사용자가 선택되었습니다. API 실행 시 토큰이 자동 발급됩니다.', 'success');
|
||||
if (!response.ok) {
|
||||
throw new Error('토큰 발급 실패');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
state = {
|
||||
token: null,
|
||||
userId: userId,
|
||||
userName: selectedOption?.dataset?.name || data.user?.name || '',
|
||||
userEmail: selectedOption?.dataset?.email || data.user?.email || '',
|
||||
actualToken: data.token // 발급된 토큰 저장
|
||||
};
|
||||
|
||||
if (typeof showToast === 'function') {
|
||||
showToast('토큰이 발급되었습니다. 복사 후 닫기를 눌러주세요.', 'success');
|
||||
}
|
||||
|
||||
saveState();
|
||||
updateUI();
|
||||
notifyChange();
|
||||
// 토큰 표시를 위해 모달을 닫지 않음 - 사용자가 직접 닫기 클릭
|
||||
} catch (err) {
|
||||
console.error('토큰 발급 실패:', err);
|
||||
if (typeof showToast === 'function') {
|
||||
showToast('토큰 발급에 실패했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
saveState();
|
||||
updateUI();
|
||||
notifyChange();
|
||||
this.closeModal();
|
||||
},
|
||||
|
||||
// 인증 초기화
|
||||
@@ -306,7 +353,8 @@ function notifyChange() {
|
||||
token: null,
|
||||
userId: null,
|
||||
userName: null,
|
||||
userEmail: null
|
||||
userEmail: null,
|
||||
actualToken: null
|
||||
};
|
||||
|
||||
const tokenInput = document.getElementById('devToolsAuthBearerToken');
|
||||
@@ -324,6 +372,86 @@ function notifyChange() {
|
||||
}
|
||||
},
|
||||
|
||||
// 테넌트 변경
|
||||
async switchTenant(tenantId) {
|
||||
if (!tenantId) return;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('tenant_id', tenantId);
|
||||
|
||||
const response = await fetch(TENANT_SWITCH_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': CSRF_TOKEN,
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('테넌트 변경 실패');
|
||||
}
|
||||
|
||||
// 사용자 목록 새로고침
|
||||
usersLoaded = false;
|
||||
await loadUsers();
|
||||
|
||||
// 헤더의 테넌트 선택도 동기화
|
||||
const headerTenantSelect = document.getElementById('tenant-select');
|
||||
if (headerTenantSelect) {
|
||||
headerTenantSelect.value = tenantId;
|
||||
}
|
||||
|
||||
if (typeof showToast === 'function') {
|
||||
showToast('회사가 변경되었습니다.', 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('테넌트 변경 실패:', err);
|
||||
if (typeof showToast === 'function') {
|
||||
showToast('회사 변경에 실패했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 토큰 복사
|
||||
async copyToken() {
|
||||
const displayToken = state.actualToken || state.token;
|
||||
if (!displayToken) {
|
||||
if (typeof showToast === 'function') {
|
||||
showToast('복사할 토큰이 없습니다.', 'warning');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(displayToken);
|
||||
if (typeof showToast === 'function') {
|
||||
showToast('토큰이 클립보드에 복사되었습니다.', 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('클립보드 복사 실패:', err);
|
||||
// Fallback: textarea를 이용한 복사
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = displayToken;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.left = '-9999px';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
if (typeof showToast === 'function') {
|
||||
showToast('토큰이 클립보드에 복사되었습니다.', 'success');
|
||||
}
|
||||
} catch (e) {
|
||||
if (typeof showToast === 'function') {
|
||||
showToast('복사에 실패했습니다. 직접 선택하여 복사해주세요.', 'error');
|
||||
}
|
||||
}
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
},
|
||||
|
||||
// 현재 토큰 반환
|
||||
getToken() {
|
||||
return state.token;
|
||||
|
||||
@@ -423,6 +423,7 @@
|
||||
|
||||
// 사용자 목록 (인증용)
|
||||
Route::get('/users', [ApiExplorerController::class, 'users'])->name('users');
|
||||
Route::post('/issue-token', [ApiExplorerController::class, 'issueToken'])->name('issue-token');
|
||||
|
||||
// API 사용 현황 및 폐기 관리
|
||||
Route::get('/usage', [ApiExplorerController::class, 'usage'])->name('usage');
|
||||
|
||||
Reference in New Issue
Block a user