- 인증 모달에 회사(테넌트) 선택 드롭다운 추가 - 헤더의 $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>
518 lines
19 KiB
PHP
518 lines
19 KiB
PHP
{{--
|
|
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 TENANT_SWITCH_ENDPOINT = '{{ route("tenant.switch") }}';
|
|
const ISSUE_TOKEN_ENDPOINT = '{{ route("dev-tools.api-explorer.issue-token") }}';
|
|
const CSRF_TOKEN = '{{ csrf_token() }}';
|
|
|
|
// 인증 상태
|
|
let state = {
|
|
token: null,
|
|
userId: null,
|
|
userName: null,
|
|
userEmail: null,
|
|
actualToken: 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;
|
|
}
|
|
|
|
// 토큰 표시 영역 업데이트
|
|
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');
|
|
}
|
|
}
|
|
}
|
|
|
|
// 콜백 호출
|
|
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();
|
|
}
|
|
},
|
|
|
|
// 인증 저장
|
|
async 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,
|
|
actualToken: token // 직접 입력한 토큰도 actualToken에 저장
|
|
};
|
|
|
|
if (typeof showToast === 'function') {
|
|
showToast('토큰이 적용되었습니다. 복사 후 닫기를 눌러주세요.', 'success');
|
|
}
|
|
|
|
saveState();
|
|
updateUI();
|
|
notifyChange();
|
|
// 토큰 표시를 위해 모달을 닫지 않음 - 사용자가 직접 닫기 클릭
|
|
} 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];
|
|
|
|
// 토큰 발급 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 (!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');
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
// 인증 초기화
|
|
clear() {
|
|
state = {
|
|
token: null,
|
|
userId: null,
|
|
userName: null,
|
|
userEmail: null,
|
|
actualToken: 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');
|
|
}
|
|
},
|
|
|
|
// 테넌트 변경
|
|
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;
|
|
},
|
|
|
|
// 현재 사용자 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>
|