feat: [API Explorer] Phase 1 완성 - 히스토리 로드, 밸리데이션, 유니코드 처리
- 히스토리 로드 기능 구현 (loadFromHistory, fillFormFromHistory) - 클라이언트 사이드 필수값 밸리데이션 추가 - 응답 본문 \xXX UTF-8 바이트 시퀀스 디코딩 (PHP 스택트레이스 한글 깨짐 해결) - sidebar에 data-operation-id 속성 추가 - history-drawer 함수 연결 수정 - Flow Tester 변수 바인딩 개선 - 마이그레이션 파일 통합 정리
This commit is contained in:
@@ -43,12 +43,16 @@ public function index(): View
|
||||
$environments = $this->explorer->getEnvironments($userId);
|
||||
$defaultEnv = $this->explorer->getDefaultEnvironment($userId);
|
||||
|
||||
// 세션에 저장된 토큰
|
||||
$savedToken = session('api_explorer_token');
|
||||
|
||||
return view('dev-tools.api-explorer.index', compact(
|
||||
'endpoints',
|
||||
'tags',
|
||||
'bookmarks',
|
||||
'environments',
|
||||
'defaultEnv'
|
||||
'defaultEnv',
|
||||
'savedToken'
|
||||
));
|
||||
}
|
||||
|
||||
@@ -119,13 +123,42 @@ public function execute(Request $request): JsonResponse
|
||||
'query' => 'nullable|array',
|
||||
'body' => 'nullable|array',
|
||||
'environment' => 'required|string',
|
||||
'token' => 'nullable|string',
|
||||
'user_id' => 'nullable|integer',
|
||||
]);
|
||||
|
||||
// Bearer 토큰 처리
|
||||
$token = null;
|
||||
$headers = $validated['headers'] ?? [];
|
||||
|
||||
// 1. 직접 입력된 토큰
|
||||
if (! empty($validated['token'])) {
|
||||
$token = $validated['token'];
|
||||
session(['api_explorer_token' => $token]);
|
||||
}
|
||||
// 2. 사용자 선택 시 Sanctum 토큰 발급
|
||||
elseif (! empty($validated['user_id'])) {
|
||||
$user = \App\Models\User::find($validated['user_id']);
|
||||
if ($user) {
|
||||
$token = $user->createToken('api-explorer', ['*'])->plainTextToken;
|
||||
session(['api_explorer_token' => $token]);
|
||||
}
|
||||
}
|
||||
// 3. 세션에 저장된 토큰 재사용
|
||||
elseif (session('api_explorer_token')) {
|
||||
$token = session('api_explorer_token');
|
||||
}
|
||||
|
||||
// Authorization 헤더 추가 (사용자 입력 토큰이 우선)
|
||||
if ($token) {
|
||||
$headers['Authorization'] = 'Bearer ' . $token;
|
||||
}
|
||||
|
||||
// API 실행
|
||||
$result = $this->requester->execute(
|
||||
$validated['method'],
|
||||
$validated['url'],
|
||||
$validated['headers'] ?? [],
|
||||
$headers,
|
||||
$validated['query'] ?? [],
|
||||
$validated['body']
|
||||
);
|
||||
@@ -384,4 +417,26 @@ public function setDefaultEnvironment(int $id): JsonResponse
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Users (for Authentication)
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* 현재 테넌트의 사용자 목록
|
||||
*/
|
||||
public function users(): JsonResponse
|
||||
{
|
||||
$tenantId = auth()->user()->tenant_id;
|
||||
|
||||
$users = \App\Models\User::where('tenant_id', $tenantId)
|
||||
->select(['id', 'name', 'email'])
|
||||
->orderBy('name')
|
||||
->limit(100)
|
||||
->get();
|
||||
|
||||
return response()->json($users);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,10 @@ public function index(): View
|
||||
->orderByDesc('created_at')
|
||||
->paginate(20);
|
||||
|
||||
return view('dev-tools.flow-tester.index', compact('flows'));
|
||||
// 세션에 저장된 토큰
|
||||
$savedToken = session('flow_tester_token');
|
||||
|
||||
return view('dev-tools.flow-tester.index', compact('flows', 'savedToken'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -329,4 +332,53 @@ public function runDetail(int $runId): View
|
||||
|
||||
return view('dev-tools.flow-tester.run-detail', compact('run'));
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Token Management
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Bearer 토큰 저장
|
||||
*/
|
||||
public function saveToken(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'token' => 'required|string',
|
||||
]);
|
||||
|
||||
session(['flow_tester_token' => $validated['token']]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '토큰이 저장되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bearer 토큰 초기화
|
||||
*/
|
||||
public function clearToken()
|
||||
{
|
||||
session()->forget('flow_tester_token');
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '토큰이 초기화되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 토큰 상태 조회
|
||||
*/
|
||||
public function tokenStatus()
|
||||
{
|
||||
$token = session('flow_tester_token');
|
||||
|
||||
return response()->json([
|
||||
'has_token' => ! empty($token),
|
||||
'token_preview' => $token ? substr($token, 0, 20).'...' : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* - {{$timestamp}} - 현재 타임스탬프
|
||||
* - {{$uuid}} - 랜덤 UUID
|
||||
* - {{$random:N}} - N자리 랜덤 숫자
|
||||
* - {{$session.token}} - 세션에 저장된 Bearer 토큰
|
||||
* - {{$faker.xxx}} - Faker 기반 랜덤 데이터 생성
|
||||
*/
|
||||
class VariableBinder
|
||||
@@ -151,6 +152,12 @@ function ($m) {
|
||||
// {{$auth.apiKey}} → .env의 API Key
|
||||
$input = str_replace('{{$auth.apiKey}}', env('FLOW_TESTER_API_KEY', ''), $input);
|
||||
|
||||
// {{$session.token}} → 세션에 저장된 Bearer 토큰
|
||||
if (str_contains($input, '{{$session.token}}')) {
|
||||
$token = session('flow_tester_token', '');
|
||||
$input = str_replace('{{$session.token}}', $token, $input);
|
||||
}
|
||||
|
||||
// {{$faker.xxx}} → Faker 기반 랜덤 데이터 생성
|
||||
$input = $this->resolveFaker($input);
|
||||
|
||||
|
||||
@@ -22,13 +22,13 @@
|
||||
'default_environments' => [
|
||||
[
|
||||
'name' => '로컬',
|
||||
'base_url' => 'http://api.sam.kr',
|
||||
'api_key' => env('API_EXPLORER_LOCAL_KEY', ''),
|
||||
'base_url' => env('API_EXPLORER_LOCAL_URL', 'http://sam-api-1'),
|
||||
'api_key' => env('FLOW_TESTER_API_KEY', ''),
|
||||
],
|
||||
[
|
||||
'name' => '개발',
|
||||
'base_url' => 'https://api.codebridge-x.com',
|
||||
'api_key' => env('API_EXPLORER_DEV_KEY', ''),
|
||||
'api_key' => env('FLOW_TESTER_API_KEY', ''),
|
||||
],
|
||||
],
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
'allowed_hosts' => [ // 화이트리스트
|
||||
'api.sam.kr',
|
||||
'api.codebridge-x.com',
|
||||
'sam-api-1', // Docker 컨테이너
|
||||
'localhost',
|
||||
'127.0.0.1',
|
||||
],
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('api_bookmarks', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->onDelete('cascade')->comment('사용자 ID');
|
||||
$table->string('endpoint', 500)->comment('API 엔드포인트');
|
||||
$table->string('method', 10)->comment('HTTP 메서드');
|
||||
$table->string('display_name', 100)->nullable()->comment('표시명');
|
||||
$table->integer('display_order')->default(0)->comment('표시 순서');
|
||||
$table->string('color', 20)->nullable()->comment('색상 코드');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['user_id', 'endpoint', 'method'], 'api_bookmarks_unique');
|
||||
$table->index('user_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('api_bookmarks');
|
||||
}
|
||||
};
|
||||
@@ -1,40 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('api_templates', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->onDelete('cascade')->comment('사용자 ID');
|
||||
$table->string('endpoint', 500)->comment('API 엔드포인트');
|
||||
$table->string('method', 10)->comment('HTTP 메서드');
|
||||
$table->string('name', 100)->comment('템플릿명');
|
||||
$table->text('description')->nullable()->comment('설명');
|
||||
$table->json('headers')->nullable()->comment('헤더 (JSON)');
|
||||
$table->json('path_params')->nullable()->comment('경로 파라미터 (JSON)');
|
||||
$table->json('query_params')->nullable()->comment('쿼리 파라미터 (JSON)');
|
||||
$table->json('body')->nullable()->comment('요청 본문 (JSON)');
|
||||
$table->boolean('is_shared')->default(false)->comment('공유 여부');
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['user_id', 'endpoint', 'method'], 'api_templates_user_endpoint');
|
||||
$table->index('is_shared');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('api_templates');
|
||||
}
|
||||
};
|
||||
@@ -1,40 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('api_histories', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->onDelete('cascade')->comment('사용자 ID');
|
||||
$table->string('endpoint', 500)->comment('API 엔드포인트');
|
||||
$table->string('method', 10)->comment('HTTP 메서드');
|
||||
$table->json('request_headers')->nullable()->comment('요청 헤더 (JSON)');
|
||||
$table->json('request_body')->nullable()->comment('요청 본문 (JSON)');
|
||||
$table->integer('response_status')->comment('응답 상태 코드');
|
||||
$table->json('response_headers')->nullable()->comment('응답 헤더 (JSON)');
|
||||
$table->longText('response_body')->nullable()->comment('응답 본문');
|
||||
$table->integer('duration_ms')->comment('소요 시간 (ms)');
|
||||
$table->string('environment', 50)->comment('환경명');
|
||||
$table->timestamp('created_at')->useCurrent()->comment('생성일시');
|
||||
|
||||
$table->index(['user_id', 'created_at'], 'api_histories_user_created');
|
||||
$table->index(['endpoint', 'method'], 'api_histories_endpoint_method');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('api_histories');
|
||||
}
|
||||
};
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('api_environments', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->onDelete('cascade')->comment('사용자 ID');
|
||||
$table->string('name', 50)->comment('환경명');
|
||||
$table->string('base_url', 500)->comment('기본 URL');
|
||||
$table->string('api_key', 500)->nullable()->comment('API Key (암호화 저장)');
|
||||
$table->text('auth_token')->nullable()->comment('인증 토큰 (암호화 저장)');
|
||||
$table->json('variables')->nullable()->comment('환경 변수 (JSON)');
|
||||
$table->boolean('is_default')->default(false)->comment('기본 환경 여부');
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('user_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('api_environments');
|
||||
}
|
||||
};
|
||||
265
public/js/fcm.js
Normal file
265
public/js/fcm.js
Normal file
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* FCM (Firebase Cloud Messaging) Push Notification Handler
|
||||
* Capacitor 앱에서만 동작, 웹 브라우저에서는 무시됨
|
||||
*
|
||||
* 필요 조건:
|
||||
* - localStorage에 'api_access_token' 저장 (API 인증용)
|
||||
* - window.SAM_CONFIG.apiBaseUrl 설정 (API 서버 주소)
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// 설정
|
||||
const CONFIG = {
|
||||
// API Base URL (Blade에서 주입하거나 기본값 사용)
|
||||
apiBaseUrl: window.SAM_CONFIG?.apiBaseUrl || 'https://api.codebridge-x.com',
|
||||
// localStorage 키
|
||||
fcmTokenKey: 'fcm_token',
|
||||
apiTokenKey: 'api_access_token',
|
||||
apiKeyHeader: window.SAM_CONFIG?.apiKey || '',
|
||||
};
|
||||
|
||||
/**
|
||||
* FCM 초기화 (페이지 로드 시 실행)
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// Capacitor 환경이 아니면 무시
|
||||
if (!window.Capacitor?.Plugins?.PushNotifications) {
|
||||
console.log('[FCM] Not running in Capacitor or PushNotifications not available');
|
||||
return;
|
||||
}
|
||||
|
||||
await initializeFCM();
|
||||
});
|
||||
|
||||
/**
|
||||
* FCM 초기화
|
||||
*/
|
||||
async function initializeFCM() {
|
||||
const { PushNotifications } = Capacitor.Plugins;
|
||||
|
||||
try {
|
||||
// 1. 권한 요청
|
||||
const perm = await PushNotifications.requestPermissions();
|
||||
console.log('[FCM] Push permission:', perm.receive);
|
||||
|
||||
if (perm.receive !== 'granted') {
|
||||
console.log('[FCM] Push permission not granted');
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 기존 리스너 제거 (중복 방지)
|
||||
PushNotifications.removeAllListeners();
|
||||
|
||||
// 3. 토큰 수신 리스너
|
||||
PushNotifications.addListener('registration', async (token) => {
|
||||
console.log('[FCM] Token received:', token.value?.substring(0, 20) + '...');
|
||||
await handleTokenRegistration(token.value);
|
||||
});
|
||||
|
||||
// 4. 등록 에러 핸들링
|
||||
PushNotifications.addListener('registrationError', (err) => {
|
||||
console.error('[FCM] Registration error:', err);
|
||||
});
|
||||
|
||||
// 5. 푸시 수신 리스너 (앱이 포그라운드일 때)
|
||||
PushNotifications.addListener('pushNotificationReceived', (notification) => {
|
||||
console.log('[FCM] Push received (foreground):', notification);
|
||||
|
||||
// Toast 알림 표시 (SweetAlert2 사용)
|
||||
if (typeof showToast === 'function') {
|
||||
showToast(notification.body || notification.title, 'info');
|
||||
}
|
||||
});
|
||||
|
||||
// 6. 푸시 액션 리스너 (알림 클릭 시)
|
||||
PushNotifications.addListener('pushNotificationActionPerformed', (action) => {
|
||||
console.log('[FCM] Push action performed:', action);
|
||||
|
||||
// 알림에 포함된 URL로 이동
|
||||
const data = action.notification.data;
|
||||
if (data && data.url) {
|
||||
window.location.href = data.url;
|
||||
}
|
||||
});
|
||||
|
||||
// 7. FCM 등록 시작
|
||||
await PushNotifications.register();
|
||||
|
||||
} catch (error) {
|
||||
console.error('[FCM] Initialization error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 등록 처리 (중복 방지 + 변경 감지)
|
||||
* @param {string} newToken - 새로 받은 FCM 토큰
|
||||
*/
|
||||
async function handleTokenRegistration(newToken) {
|
||||
const oldToken = localStorage.getItem(CONFIG.fcmTokenKey);
|
||||
|
||||
// 토큰이 동일하면 재등록 생략
|
||||
if (oldToken === newToken) {
|
||||
console.log('[FCM] Token unchanged, skipping registration');
|
||||
return;
|
||||
}
|
||||
|
||||
// API로 토큰 등록
|
||||
const success = await registerTokenToServer(newToken);
|
||||
|
||||
if (success) {
|
||||
// 성공 시 localStorage에 저장
|
||||
localStorage.setItem(CONFIG.fcmTokenKey, newToken);
|
||||
console.log('[FCM] Token saved to localStorage');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FCM 토큰을 서버에 등록
|
||||
* @param {string} token - FCM 토큰
|
||||
* @returns {boolean} 성공 여부
|
||||
*/
|
||||
async function registerTokenToServer(token) {
|
||||
const accessToken = localStorage.getItem(CONFIG.apiTokenKey);
|
||||
|
||||
if (!accessToken) {
|
||||
console.warn('[FCM] No API access token found, skipping registration');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
};
|
||||
|
||||
// API Key가 있으면 추가
|
||||
if (CONFIG.apiKeyHeader) {
|
||||
headers['X-API-KEY'] = CONFIG.apiKeyHeader;
|
||||
}
|
||||
|
||||
const response = await fetch(`${CONFIG.apiBaseUrl}/api/push/register-token`, {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
body: JSON.stringify({
|
||||
token: token,
|
||||
platform: getDevicePlatform(),
|
||||
device_name: getDeviceName(),
|
||||
app_version: getAppVersion(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
console.log('[FCM] Token registered successfully:', result);
|
||||
return true;
|
||||
} else {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
console.error('[FCM] Token registration failed:', response.status, error);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[FCM] Failed to send token to server:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FCM 토큰 해제 (로그아웃 시 호출)
|
||||
* @returns {boolean} 성공 여부
|
||||
*/
|
||||
async function unregisterToken() {
|
||||
const token = localStorage.getItem(CONFIG.fcmTokenKey);
|
||||
const accessToken = localStorage.getItem(CONFIG.apiTokenKey);
|
||||
|
||||
if (!token) {
|
||||
console.log('[FCM] No token to unregister');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
// 토큰은 있지만 API 인증이 없으면 로컬만 삭제
|
||||
localStorage.removeItem(CONFIG.fcmTokenKey);
|
||||
console.log('[FCM] Token removed from localStorage (no API auth)');
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
};
|
||||
|
||||
if (CONFIG.apiKeyHeader) {
|
||||
headers['X-API-KEY'] = CONFIG.apiKeyHeader;
|
||||
}
|
||||
|
||||
const response = await fetch(`${CONFIG.apiBaseUrl}/api/push/unregister-token`, {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
body: JSON.stringify({
|
||||
token: token,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
console.log('[FCM] Token unregistered successfully');
|
||||
} else {
|
||||
console.warn('[FCM] Token unregister failed, but continuing logout');
|
||||
}
|
||||
|
||||
// 성공/실패와 관계없이 로컬 토큰 삭제
|
||||
localStorage.removeItem(CONFIG.fcmTokenKey);
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
console.error('[FCM] Failed to unregister token:', error);
|
||||
// 에러가 나도 로컬 토큰은 삭제
|
||||
localStorage.removeItem(CONFIG.fcmTokenKey);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 디바이스 플랫폼 감지
|
||||
* @returns {string} 'ios' | 'android' | 'web'
|
||||
*/
|
||||
function getDevicePlatform() {
|
||||
if (window.Capacitor) {
|
||||
const platform = Capacitor.getPlatform();
|
||||
if (platform === 'ios') return 'ios';
|
||||
if (platform === 'android') return 'android';
|
||||
}
|
||||
return 'web';
|
||||
}
|
||||
|
||||
/**
|
||||
* 디바이스명 가져오기
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function getDeviceName() {
|
||||
if (window.Capacitor?.Plugins?.Device) {
|
||||
// Capacitor Device 플러그인이 있으면 비동기로 가져와야 함
|
||||
// 여기서는 간단히 null 반환 (필요시 별도 구현)
|
||||
return null;
|
||||
}
|
||||
return navigator.userAgent?.substring(0, 100) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 앱 버전 가져오기
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function getAppVersion() {
|
||||
return window.SAM_CONFIG?.appVersion || null;
|
||||
}
|
||||
|
||||
// 전역으로 노출 (로그아웃 시 호출용)
|
||||
window.FCM = {
|
||||
unregisterToken: unregisterToken,
|
||||
reinitialize: initializeFCM,
|
||||
};
|
||||
|
||||
})();
|
||||
@@ -189,12 +189,23 @@
|
||||
<option value="{{ $env->id }}"
|
||||
data-base-url="{{ $env->base_url }}"
|
||||
data-api-key="{{ $env->decrypted_api_key }}"
|
||||
data-auth-token="{{ $env->decrypted_auth_token }}"
|
||||
{{ $env->is_default ? 'selected' : '' }}>
|
||||
{{ $env->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</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="인증 설정">
|
||||
<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>
|
||||
</button>
|
||||
|
||||
<!-- 히스토리 버튼 -->
|
||||
<button onclick="toggleHistoryDrawer()" class="p-2 text-gray-600 hover:bg-gray-100 rounded-lg" title="히스토리">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -217,24 +228,20 @@
|
||||
<!-- 사이드바: API 목록 -->
|
||||
<div class="api-sidebar">
|
||||
<div class="api-sidebar-header">
|
||||
<!-- 검색 -->
|
||||
<!-- 검색 (클라이언트 사이드 필터링) -->
|
||||
<input type="text"
|
||||
id="search-input"
|
||||
name="search"
|
||||
placeholder="API 검색..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
hx-get="{{ route('dev-tools.api-explorer.endpoints') }}"
|
||||
hx-trigger="input changed delay:300ms"
|
||||
hx-target="#endpoint-list"
|
||||
hx-include="#method-filters"
|
||||
oninput="filterEndpoints()"
|
||||
autocomplete="off">
|
||||
|
||||
<!-- 메서드 필터 -->
|
||||
<div id="method-filters" class="flex flex-wrap gap-1 mt-2">
|
||||
@foreach(['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as $method)
|
||||
<label class="inline-flex items-center">
|
||||
<input type="checkbox" name="methods[]" value="{{ $method }}" class="hidden method-filter">
|
||||
<span class="method-badge method-{{ strtolower($method) }} opacity-40 cursor-pointer hover:opacity-100 transition-opacity">
|
||||
<label class="inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" value="{{ $method }}" class="hidden method-filter" onchange="toggleMethodFilter(this)">
|
||||
<span class="method-badge method-{{ strtolower($method) }} opacity-40 hover:opacity-100 transition-opacity">
|
||||
{{ $method }}
|
||||
</span>
|
||||
</label>
|
||||
@@ -282,6 +289,89 @@ 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>
|
||||
|
||||
<!-- 히스토리 서랍 (오버레이) -->
|
||||
<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">
|
||||
<div class="h-full flex flex-col">
|
||||
@@ -306,9 +396,141 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
// 전체 엔드포인트 데이터 (클라이언트 사이드 필터링용)
|
||||
const allEndpoints = @json($endpoints->flatten(1)->values());
|
||||
const bookmarkedEndpoints = @json($bookmarks->map(fn($b) => $b->endpoint . '|' . $b->method)->toArray());
|
||||
let activeMethodFilters = [];
|
||||
|
||||
// 현재 선택된 엔드포인트
|
||||
let currentEndpoint = null;
|
||||
|
||||
// 마지막 요청/응답 데이터 (재전송, AI 분석용)
|
||||
let lastRequestData = null;
|
||||
let lastResponseData = null;
|
||||
|
||||
// 메서드 필터 토글
|
||||
function toggleMethodFilter(checkbox) {
|
||||
const badge = checkbox.nextElementSibling;
|
||||
const method = checkbox.value;
|
||||
|
||||
if (checkbox.checked) {
|
||||
badge.classList.remove('opacity-40');
|
||||
if (!activeMethodFilters.includes(method)) {
|
||||
activeMethodFilters.push(method);
|
||||
}
|
||||
} else {
|
||||
badge.classList.add('opacity-40');
|
||||
activeMethodFilters = activeMethodFilters.filter(m => m !== method);
|
||||
}
|
||||
|
||||
filterEndpoints();
|
||||
}
|
||||
|
||||
// 클라이언트 사이드 필터링
|
||||
function filterEndpoints() {
|
||||
const searchQuery = document.getElementById('search-input').value.toLowerCase().trim();
|
||||
|
||||
// 필터링
|
||||
let filtered = allEndpoints.filter(endpoint => {
|
||||
// 메서드 필터
|
||||
if (activeMethodFilters.length > 0 && !activeMethodFilters.includes(endpoint.method)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 검색어 필터
|
||||
if (searchQuery) {
|
||||
const searchTargets = [
|
||||
endpoint.path || '',
|
||||
endpoint.summary || '',
|
||||
endpoint.description || '',
|
||||
endpoint.operationId || '',
|
||||
...(endpoint.tags || [])
|
||||
].map(s => s.toLowerCase());
|
||||
|
||||
return searchTargets.some(target => target.includes(searchQuery));
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// 태그별 그룹핑
|
||||
const grouped = {};
|
||||
filtered.forEach(endpoint => {
|
||||
const tag = endpoint.tags?.[0] || '기타';
|
||||
if (!grouped[tag]) grouped[tag] = [];
|
||||
grouped[tag].push(endpoint);
|
||||
});
|
||||
|
||||
renderSidebar(grouped);
|
||||
}
|
||||
|
||||
// 사이드바 렌더링
|
||||
function renderSidebar(groupedEndpoints) {
|
||||
const container = document.getElementById('endpoint-list');
|
||||
const tags = Object.keys(groupedEndpoints).sort();
|
||||
|
||||
if (tags.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="text-center text-gray-400 py-8">
|
||||
<svg class="w-8 h-8 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p class="text-sm">검색 결과가 없습니다</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
tags.forEach(tag => {
|
||||
const endpoints = groupedEndpoints[tag];
|
||||
const tagSlug = tag.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
||||
|
||||
html += `
|
||||
<div class="tag-group">
|
||||
<div class="tag-header" onclick="toggleTagGroup('${tagSlug}')">
|
||||
<span class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-gray-400 transition-transform rotate-90" id="chevron-${tagSlug}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
${escapeHtml(tag)}
|
||||
</span>
|
||||
<span class="text-xs text-gray-400">${endpoints.length}</span>
|
||||
</div>
|
||||
<div id="tag-${tagSlug}" class="space-y-0.5">
|
||||
`;
|
||||
|
||||
endpoints.forEach(endpoint => {
|
||||
const isBookmarked = bookmarkedEndpoints.includes(endpoint.path + '|' + endpoint.method);
|
||||
const bookmarkClass = isBookmarked ? 'text-yellow-500' : 'text-gray-400';
|
||||
|
||||
html += `
|
||||
<div class="endpoint-item" data-operation-id="${endpoint.operationId}" onclick="selectEndpoint('${endpoint.operationId}', this)">
|
||||
<span class="method-badge method-${endpoint.method.toLowerCase()}">
|
||||
${endpoint.method}
|
||||
</span>
|
||||
<span class="endpoint-path" title="${escapeHtml(endpoint.summary || endpoint.path)}">
|
||||
${escapeHtml(endpoint.path)}
|
||||
</span>
|
||||
<button onclick="event.stopPropagation(); toggleBookmark('${escapeHtml(endpoint.path)}', '${endpoint.method}', this)"
|
||||
class="${bookmarkClass} hover:text-yellow-500">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
html += `
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// 환경 설정
|
||||
function getSelectedEnvironment() {
|
||||
const select = document.getElementById('environment-select');
|
||||
@@ -317,7 +539,8 @@ function getSelectedEnvironment() {
|
||||
id: select.value,
|
||||
name: option.text,
|
||||
baseUrl: option.dataset.baseUrl,
|
||||
apiKey: option.dataset.apiKey
|
||||
apiKey: option.dataset.apiKey,
|
||||
authToken: option.dataset.authToken
|
||||
};
|
||||
}
|
||||
|
||||
@@ -342,6 +565,26 @@ function selectEndpoint(operationId, element) {
|
||||
const formData = new FormData(form);
|
||||
const env = getSelectedEnvironment();
|
||||
|
||||
// 필수값 밸리데이션
|
||||
const requiredFields = form.querySelectorAll('[required]');
|
||||
const missingFields = [];
|
||||
requiredFields.forEach(field => {
|
||||
const value = field.value?.trim();
|
||||
if (!value) {
|
||||
const label = field.closest('div')?.querySelector('label')?.textContent?.replace('*', '').trim()
|
||||
|| field.name.replace('path_', '').replace('query_', '');
|
||||
missingFields.push(label);
|
||||
field.classList.add('border-red-500', 'bg-red-50');
|
||||
} else {
|
||||
field.classList.remove('border-red-500', 'bg-red-50');
|
||||
}
|
||||
});
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
showToast(`필수값을 입력해주세요: ${missingFields.join(', ')}`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 요청 데이터 구성
|
||||
const method = formData.get('method');
|
||||
let url = env.baseUrl + formData.get('endpoint');
|
||||
@@ -372,6 +615,9 @@ function selectEndpoint(operationId, element) {
|
||||
if (env.apiKey) {
|
||||
headers['X-API-KEY'] = env.apiKey;
|
||||
}
|
||||
if (env.authToken) {
|
||||
headers['Authorization'] = 'Bearer ' + env.authToken;
|
||||
}
|
||||
|
||||
// 바디
|
||||
let body = null;
|
||||
@@ -391,6 +637,26 @@ function selectEndpoint(operationId, 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;
|
||||
}
|
||||
|
||||
// 마지막 요청 데이터 저장 (재전송, AI 분석용)
|
||||
lastRequestData = {
|
||||
method: method,
|
||||
url: url,
|
||||
endpoint: formData.get('endpoint'),
|
||||
headers: headers,
|
||||
query: queryParams,
|
||||
body: body,
|
||||
environment: env.name,
|
||||
authPayload: authPayload
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ route("dev-tools.api-explorer.execute") }}', {
|
||||
method: 'POST',
|
||||
@@ -404,7 +670,8 @@ function selectEndpoint(operationId, element) {
|
||||
headers: headers,
|
||||
query: queryParams,
|
||||
body: body,
|
||||
environment: env.name
|
||||
environment: env.name,
|
||||
...authPayload
|
||||
})
|
||||
});
|
||||
|
||||
@@ -429,6 +696,15 @@ function displayResponse(result) {
|
||||
const meta = document.getElementById('response-meta');
|
||||
const content = document.getElementById('response-content');
|
||||
|
||||
// 마지막 응답 데이터 저장
|
||||
lastResponseData = result;
|
||||
|
||||
// 응답 컨테이너 스타일 변경 (가운데 정렬 → 왼쪽 정렬)
|
||||
content.className = 'text-left';
|
||||
|
||||
// 오류 여부 판단
|
||||
const isError = result.status >= 400 || result.status === 0;
|
||||
|
||||
// 메타 정보
|
||||
const statusClass = result.status >= 200 && result.status < 300 ? 'status-2xx' :
|
||||
result.status >= 300 && result.status < 400 ? 'status-3xx' :
|
||||
@@ -441,33 +717,54 @@ function displayResponse(result) {
|
||||
<span>${result.duration_ms}ms</span>
|
||||
`;
|
||||
|
||||
// 본문
|
||||
const bodyStr = typeof result.body === 'object'
|
||||
? JSON.stringify(result.body, null, 2)
|
||||
: result.body;
|
||||
// 본문 (유니코드/슬래시 이스케이프 처리)
|
||||
const bodyStr = formatJsonBody(result.body);
|
||||
|
||||
// 오류 시 액션 버튼
|
||||
const errorActions = isError ? `
|
||||
<div class="flex items-center gap-2 p-3 bg-red-50 border border-red-200 rounded-lg mb-4">
|
||||
<svg class="w-5 h-5 text-red-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||
</svg>
|
||||
<span class="text-sm text-red-700 flex-1">요청이 실패했습니다 (${result.status || 'Error'})</span>
|
||||
<button onclick="copyForAiAnalysis()" class="px-3 py-1.5 text-xs bg-purple-600 hover:bg-purple-700 text-white rounded flex items-center gap-1.5 transition">
|
||||
<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="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>
|
||||
AI 분석
|
||||
</button>
|
||||
<button onclick="retryLastRequest()" class="px-3 py-1.5 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded flex items-center gap-1.5 transition">
|
||||
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||
</svg>
|
||||
재전송
|
||||
</button>
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
content.innerHTML = `
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-4 text-left">
|
||||
${errorActions}
|
||||
<!-- 헤더 -->
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-2">응답 헤더</h4>
|
||||
<div class="bg-gray-50 rounded p-3 text-xs font-mono overflow-x-auto">
|
||||
<div class="text-left">
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-2 text-left">응답 헤더</h4>
|
||||
<div class="bg-gray-50 rounded p-3 text-xs font-mono overflow-x-auto text-left">
|
||||
${Object.entries(result.headers || {}).map(([k, v]) =>
|
||||
`<div><span class="text-gray-500">${k}:</span> ${Array.isArray(v) ? v.join(', ') : v}</div>`
|
||||
`<div class="text-left"><span class="text-gray-500">${k}:</span> ${Array.isArray(v) ? v.join(', ') : v}</div>`
|
||||
).join('')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 본문 -->
|
||||
<div>
|
||||
<div class="text-left">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h4 class="text-sm font-medium text-gray-700">응답 본문</h4>
|
||||
<h4 class="text-sm font-medium text-gray-700 text-left">응답 본문</h4>
|
||||
<button onclick="copyToClipboard(this.dataset.content)" data-content="${encodeURIComponent(bodyStr)}"
|
||||
class="text-xs text-blue-600 hover:text-blue-700">
|
||||
복사
|
||||
</button>
|
||||
</div>
|
||||
<pre class="bg-gray-900 text-gray-100 rounded p-3 text-xs font-mono overflow-x-auto max-h-96">${escapeHtml(bodyStr)}</pre>
|
||||
<pre class="bg-gray-900 text-gray-100 rounded p-3 text-xs font-mono overflow-x-auto max-h-96 text-left whitespace-pre-wrap">${escapeHtml(bodyStr)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -508,6 +805,125 @@ function toggleHistoryDrawer() {
|
||||
'<div class="text-center text-gray-400 py-8">히스토리가 없습니다.</div>';
|
||||
}
|
||||
|
||||
// 히스토리에서 요청 로드
|
||||
async function loadFromHistory(historyId) {
|
||||
try {
|
||||
// 1. 히스토리 데이터 가져오기
|
||||
const response = await fetch(`/dev-tools/api-explorer/history/${historyId}/replay`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
showToast('히스토리를 불러올 수 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const historyData = await response.json();
|
||||
|
||||
// 2. 해당 endpoint와 method로 allEndpoints에서 API 찾기
|
||||
const matchedEndpoint = allEndpoints.find(ep =>
|
||||
ep.path === historyData.endpoint && ep.method === historyData.method
|
||||
);
|
||||
|
||||
if (!matchedEndpoint) {
|
||||
// API 스펙에서 찾을 수 없는 경우 (삭제된 API 등)
|
||||
showToast('해당 API를 찾을 수 없습니다. 스펙이 변경되었을 수 있습니다.', 'error');
|
||||
toggleHistoryDrawer();
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 해당 엔드포인트 패널 로드 (HTMX)
|
||||
const panelUrl = `/dev-tools/api-explorer/endpoints/${matchedEndpoint.operationId}`;
|
||||
|
||||
// 패널 로드 후 데이터 채우기를 위해 pending 데이터 저장
|
||||
window.pendingHistoryData = historyData;
|
||||
|
||||
// HTMX로 패널 로드
|
||||
htmx.ajax('GET', panelUrl, {
|
||||
target: '#request-panel',
|
||||
swap: 'innerHTML'
|
||||
}).then(() => {
|
||||
// 패널 로드 완료 후 데이터 채우기
|
||||
setTimeout(() => {
|
||||
fillFormFromHistory(window.pendingHistoryData);
|
||||
window.pendingHistoryData = null;
|
||||
}, 100); // DOM 렌더링 대기
|
||||
});
|
||||
|
||||
// 4. 사이드바에서 해당 API 활성화
|
||||
const endpointItems = document.querySelectorAll('.endpoint-item');
|
||||
endpointItems.forEach(item => {
|
||||
item.classList.remove('active');
|
||||
if (item.dataset.operationId === matchedEndpoint.operationId) {
|
||||
item.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
// 5. 히스토리 드로어 닫기
|
||||
toggleHistoryDrawer();
|
||||
showToast('히스토리가 로드되었습니다.');
|
||||
|
||||
} catch (error) {
|
||||
console.error('히스토리 로드 오류:', error);
|
||||
showToast('히스토리 로드 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 히스토리 데이터로 폼 채우기
|
||||
function fillFormFromHistory(data) {
|
||||
if (!data) return;
|
||||
|
||||
const form = document.querySelector('#request-panel form');
|
||||
if (!form) return;
|
||||
|
||||
// Path 파라미터 채우기 (endpoint에서 추출)
|
||||
// 예: /api/v1/users/{id} 에서 {id} 부분
|
||||
const pathRegex = /\{([^}]+)\}/g;
|
||||
const endpoint = data.endpoint;
|
||||
let match;
|
||||
const pathValues = {};
|
||||
|
||||
// endpoint 패턴과 실제 경로를 비교하여 path 파라미터 값 추출
|
||||
const currentEndpointInput = form.querySelector('[name="endpoint"]');
|
||||
if (currentEndpointInput) {
|
||||
const pattern = currentEndpointInput.value;
|
||||
const patternParts = pattern.split('/');
|
||||
const endpointParts = endpoint.split('/');
|
||||
|
||||
patternParts.forEach((part, index) => {
|
||||
if (part.startsWith('{') && part.endsWith('}')) {
|
||||
const paramName = part.slice(1, -1);
|
||||
if (endpointParts[index]) {
|
||||
pathValues[paramName] = endpointParts[index];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Path 파라미터 입력 필드에 값 채우기
|
||||
Object.entries(pathValues).forEach(([key, value]) => {
|
||||
const input = form.querySelector(`[name="path_${key}"]`);
|
||||
if (input) input.value = value;
|
||||
});
|
||||
}
|
||||
|
||||
// Request Body 채우기
|
||||
if (data.body) {
|
||||
const bodyTextarea = form.querySelector('[name="body"]');
|
||||
if (bodyTextarea) {
|
||||
const bodyContent = typeof data.body === 'string'
|
||||
? data.body
|
||||
: JSON.stringify(data.body, null, 2);
|
||||
bodyTextarea.value = bodyContent;
|
||||
}
|
||||
}
|
||||
|
||||
// Query 파라미터는 히스토리에 별도 저장되지 않으므로 생략
|
||||
// (필요시 endpoint URL에서 파싱하여 채울 수 있음)
|
||||
}
|
||||
|
||||
// 즐겨찾기 토글
|
||||
async function toggleBookmark(endpoint, method, button) {
|
||||
const response = await fetch('{{ route("dev-tools.api-explorer.bookmarks.toggle") }}', {
|
||||
@@ -536,22 +952,55 @@ function toggleHistoryDrawer() {
|
||||
});
|
||||
}
|
||||
|
||||
// 메서드 필터 토글
|
||||
document.querySelectorAll('.method-filter').forEach(checkbox => {
|
||||
checkbox.addEventListener('change', function() {
|
||||
const badge = this.nextElementSibling;
|
||||
if (this.checked) {
|
||||
badge.classList.remove('opacity-40');
|
||||
} else {
|
||||
badge.classList.add('opacity-40');
|
||||
}
|
||||
|
||||
// 필터 적용
|
||||
htmx.trigger('#search-input', 'keyup');
|
||||
});
|
||||
});
|
||||
|
||||
// 유틸리티
|
||||
|
||||
// JSON 본문 포맷팅 (들여쓰기 적용 + \xXX UTF-8 바이트 디코딩)
|
||||
function formatJsonBody(body) {
|
||||
if (!body) return '';
|
||||
|
||||
try {
|
||||
// 문자열이면 JSON 파싱 시도
|
||||
let obj = typeof body === 'string' ? JSON.parse(body) : body;
|
||||
|
||||
// JSON.stringify 전에 객체 내 문자열의 \xXX 시퀀스 디코딩
|
||||
obj = decodeHexEscapes(obj);
|
||||
|
||||
// JSON으로 다시 포맷팅 (들여쓰기 포함)
|
||||
return JSON.stringify(obj, null, 2);
|
||||
} catch (e) {
|
||||
// JSON이 아닌 경우 그대로 반환
|
||||
return typeof body === 'string' ? body : JSON.stringify(body, null, 2);
|
||||
}
|
||||
}
|
||||
|
||||
// 객체 내 문자열의 \xXX UTF-8 바이트 시퀀스를 재귀적으로 디코딩
|
||||
function decodeHexEscapes(obj) {
|
||||
if (typeof obj === 'string') {
|
||||
// \xXX 패턴을 UTF-8로 디코딩
|
||||
return obj.replace(/((?:\\x[0-9a-fA-F]{2})+)/g, (match) => {
|
||||
try {
|
||||
const hexPairs = match.match(/\\x([0-9a-fA-F]{2})/g);
|
||||
if (!hexPairs) return match;
|
||||
const bytes = hexPairs.map(h => parseInt(h.substring(2), 16));
|
||||
return new TextDecoder('utf-8').decode(new Uint8Array(bytes));
|
||||
} catch (e) {
|
||||
return match;
|
||||
}
|
||||
});
|
||||
}
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(item => decodeHexEscapes(item));
|
||||
}
|
||||
if (obj && typeof obj === 'object') {
|
||||
const result = {};
|
||||
for (const key in obj) {
|
||||
result[key] = decodeHexEscapes(obj[key]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
@@ -583,6 +1032,265 @@ 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 분석 / 재전송 함수
|
||||
// ==========================================
|
||||
|
||||
// AI 분석용 복사
|
||||
function copyForAiAnalysis() {
|
||||
if (!lastRequestData || !lastResponseData) {
|
||||
showToast('요청/응답 데이터가 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const requestBody = lastRequestData.body
|
||||
? JSON.stringify(lastRequestData.body, null, 2)
|
||||
: '(없음)';
|
||||
|
||||
const responseBody = typeof lastResponseData.body === 'object'
|
||||
? JSON.stringify(lastResponseData.body, null, 2)
|
||||
: lastResponseData.body || '(없음)';
|
||||
|
||||
const analysisText = `## API 오류 분석 요청
|
||||
|
||||
### 요청 정보
|
||||
- **Method**: ${lastRequestData.method}
|
||||
- **URL**: ${lastRequestData.url}
|
||||
- **Environment**: ${lastRequestData.environment}
|
||||
|
||||
### 요청 헤더
|
||||
\`\`\`json
|
||||
${JSON.stringify(lastRequestData.headers, null, 2)}
|
||||
\`\`\`
|
||||
|
||||
### 요청 본문
|
||||
\`\`\`json
|
||||
${requestBody}
|
||||
\`\`\`
|
||||
|
||||
### 응답 정보
|
||||
- **Status**: ${lastResponseData.status}
|
||||
- **Duration**: ${lastResponseData.duration_ms}ms
|
||||
|
||||
### 응답 헤더
|
||||
\`\`\`json
|
||||
${JSON.stringify(lastResponseData.headers, null, 2)}
|
||||
\`\`\`
|
||||
|
||||
### 응답 본문
|
||||
\`\`\`json
|
||||
${responseBody}
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
위 API 요청이 실패했습니다. 원인을 분석하고 해결 방법을 제안해주세요.`;
|
||||
|
||||
navigator.clipboard.writeText(analysisText).then(() => {
|
||||
showToast('AI 분석용 내용이 클립보드에 복사되었습니다.\nClaude나 ChatGPT에 붙여넣기 하세요.', 'success');
|
||||
}).catch(err => {
|
||||
console.error('복사 실패:', err);
|
||||
showToast('복사에 실패했습니다.', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// 마지막 요청 재전송
|
||||
async function retryLastRequest() {
|
||||
if (!lastRequestData) {
|
||||
showToast('재전송할 요청 데이터가 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 응답 패널에 로딩 표시
|
||||
const content = document.getElementById('response-content');
|
||||
content.innerHTML = `
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<svg class="w-8 h-8 animate-spin text-blue-500" 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>
|
||||
<span class="ml-3 text-gray-600">재전송 중...</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ route("dev-tools.api-explorer.execute") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
method: lastRequestData.method,
|
||||
url: lastRequestData.url,
|
||||
headers: lastRequestData.headers,
|
||||
query: lastRequestData.query,
|
||||
body: lastRequestData.body,
|
||||
environment: lastRequestData.environment,
|
||||
...lastRequestData.authPayload
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
displayResponse(result);
|
||||
|
||||
if (result.status >= 200 && result.status < 400) {
|
||||
showToast('재전송 성공!', 'success');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
displayResponse({
|
||||
status: 0,
|
||||
headers: {},
|
||||
body: { error: true, message: error.message },
|
||||
duration_ms: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 토스트 메시지 (타입 지원)
|
||||
function showToast(message, type = 'success') {
|
||||
const colors = {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
@else
|
||||
<div class="divide-y divide-gray-100">
|
||||
@foreach($histories as $history)
|
||||
<div class="p-3 hover:bg-gray-50 cursor-pointer" onclick="replayHistory({{ $history->id }})">
|
||||
<div class="p-3 hover:bg-gray-50 cursor-pointer" onclick="loadFromHistory({{ $history->id }})">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="method-badge method-{{ strtolower($history->method) }}">
|
||||
@@ -32,20 +32,3 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<script>
|
||||
async function replayHistory(historyId) {
|
||||
const response = await fetch(`/dev-tools/api-explorer/history/${historyId}/replay`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// TODO: 요청 패널에 데이터 채우기
|
||||
showToast('히스토리가 로드되었습니다.');
|
||||
toggleHistoryDrawer();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -48,7 +48,7 @@ class="text-yellow-500 hover:text-yellow-600">
|
||||
@php
|
||||
$isBookmarked = $bookmarks->where('endpoint', $endpoint['path'])->where('method', $endpoint['method'])->isNotEmpty();
|
||||
@endphp
|
||||
<div class="endpoint-item" onclick="selectEndpoint('{{ $endpoint['operationId'] }}', this)">
|
||||
<div class="endpoint-item" data-operation-id="{{ $endpoint['operationId'] }}" onclick="selectEndpoint('{{ $endpoint['operationId'] }}', this)">
|
||||
<span class="method-badge method-{{ strtolower($endpoint['method']) }}">
|
||||
{{ $endpoint['method'] }}
|
||||
</span>
|
||||
|
||||
@@ -7,6 +7,15 @@
|
||||
<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="openAuthModal()" 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' }}">
|
||||
{{ $savedToken ? '인증됨' : '인증 필요' }}
|
||||
</span>
|
||||
</button>
|
||||
<!-- JSON 작성 가이드 버튼 -->
|
||||
<button onclick="GuideModal.open()"
|
||||
class="bg-gray-100 hover:bg-gray-200 text-gray-700 px-4 py-2 rounded-lg transition flex items-center gap-2">
|
||||
@@ -423,6 +432,59 @@ 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>
|
||||
|
||||
<!-- 토큰 입력 -->
|
||||
<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 ?? '' }}">
|
||||
@if($savedToken)
|
||||
<p class="mt-1 text-xs text-green-600">✅ 세션에 저장된 토큰이 있습니다.</p>
|
||||
@endif
|
||||
<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' }}">
|
||||
{{ $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>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
@@ -658,5 +720,109 @@ 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');
|
||||
}
|
||||
|
||||
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);
|
||||
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);
|
||||
showToast('인증이 초기화되었습니다.', 'info');
|
||||
} else {
|
||||
showToast(data.message || '초기화 실패', 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showToast('오류 발생: ' + error.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
@@ -93,9 +93,9 @@ class="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 hover
|
||||
<div class="border-t border-gray-200 my-1"></div>
|
||||
|
||||
<!-- Logout -->
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
<form method="POST" action="{{ route('logout') }}" id="logout-form">
|
||||
@csrf
|
||||
<button type="submit" class="block w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-gray-100">
|
||||
<button type="button" onclick="handleLogout()" class="block w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-gray-100">
|
||||
로그아웃
|
||||
</button>
|
||||
</form>
|
||||
@@ -114,5 +114,23 @@ class="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 hover
|
||||
userMenu.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 로그아웃 처리 (FCM 토큰 해제 후 로그아웃)
|
||||
*/
|
||||
async function handleLogout() {
|
||||
// FCM 토큰 해제 시도 (window.FCM이 있는 경우에만)
|
||||
if (window.FCM && typeof window.FCM.unregisterToken === 'function') {
|
||||
try {
|
||||
await window.FCM.unregisterToken();
|
||||
console.log('[Logout] FCM token unregistered');
|
||||
} catch (error) {
|
||||
console.warn('[Logout] FCM unregister failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 로그아웃 폼 제출
|
||||
document.getElementById('logout-form').submit();
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
@@ -283,6 +283,11 @@
|
||||
Route::post('/', [FlowTesterController::class, 'store'])->name('store');
|
||||
Route::post('/validate-json', [FlowTesterController::class, 'validateJson'])->name('validate-json');
|
||||
|
||||
// 토큰 관리 라우트
|
||||
Route::post('/token/save', [FlowTesterController::class, 'saveToken'])->name('token.save');
|
||||
Route::post('/token/clear', [FlowTesterController::class, 'clearToken'])->name('token.clear');
|
||||
Route::get('/token/status', [FlowTesterController::class, 'tokenStatus'])->name('token.status');
|
||||
|
||||
// /runs/* 관련 라우트 (고정 경로)
|
||||
Route::get('/runs/{runId}/status', [FlowTesterController::class, 'runStatus'])->name('run-status');
|
||||
Route::get('/runs/{runId}', [FlowTesterController::class, 'runDetail'])->name('run-detail');
|
||||
@@ -334,6 +339,9 @@
|
||||
Route::post('/environments', [ApiExplorerController::class, 'storeEnvironment'])->name('environments.store');
|
||||
Route::put('/environments/{id}', [ApiExplorerController::class, 'updateEnvironment'])->name('environments.update');
|
||||
Route::delete('/environments/{id}', [ApiExplorerController::class, 'deleteEnvironment'])->name('environments.destroy');
|
||||
|
||||
// 사용자 목록 (인증용)
|
||||
Route::get('/users', [ApiExplorerController::class, 'users'])->name('users');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user