Files
sam-manage/resources/views/dev-tools/partials/auth-scripts.blade.php
kent f70f75fb22 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>
2026-01-05 15:10:09 +09:00

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>