diff --git a/app/Http/Controllers/DevTools/ApiExplorerController.php b/app/Http/Controllers/DevTools/ApiExplorerController.php
index bc31d7f3..3cb65d61 100644
--- a/app/Http/Controllers/DevTools/ApiExplorerController.php
+++ b/app/Http/Controllers/DevTools/ApiExplorerController.php
@@ -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);
+ }
}
diff --git a/app/Http/Controllers/DevTools/FlowTesterController.php b/app/Http/Controllers/DevTools/FlowTesterController.php
index af80aec0..711f5485 100644
--- a/app/Http/Controllers/DevTools/FlowTesterController.php
+++ b/app/Http/Controllers/DevTools/FlowTesterController.php
@@ -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,
+ ]);
+ }
}
diff --git a/app/Services/FlowTester/VariableBinder.php b/app/Services/FlowTester/VariableBinder.php
index 4d7a5e31..5e186337 100644
--- a/app/Services/FlowTester/VariableBinder.php
+++ b/app/Services/FlowTester/VariableBinder.php
@@ -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);
diff --git a/config/api-explorer.php b/config/api-explorer.php
index 81270148..29361919 100644
--- a/config/api-explorer.php
+++ b/config/api-explorer.php
@@ -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',
],
diff --git a/database/migrations/2025_12_17_000001_create_api_bookmarks_table.php b/database/migrations/2025_12_17_000001_create_api_bookmarks_table.php
deleted file mode 100644
index 245f8a40..00000000
--- a/database/migrations/2025_12_17_000001_create_api_bookmarks_table.php
+++ /dev/null
@@ -1,36 +0,0 @@
-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');
- }
-};
diff --git a/database/migrations/2025_12_17_000002_create_api_templates_table.php b/database/migrations/2025_12_17_000002_create_api_templates_table.php
deleted file mode 100644
index 513ea418..00000000
--- a/database/migrations/2025_12_17_000002_create_api_templates_table.php
+++ /dev/null
@@ -1,40 +0,0 @@
-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');
- }
-};
diff --git a/database/migrations/2025_12_17_000003_create_api_histories_table.php b/database/migrations/2025_12_17_000003_create_api_histories_table.php
deleted file mode 100644
index 561744c1..00000000
--- a/database/migrations/2025_12_17_000003_create_api_histories_table.php
+++ /dev/null
@@ -1,40 +0,0 @@
-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');
- }
-};
diff --git a/database/migrations/2025_12_17_000004_create_api_environments_table.php b/database/migrations/2025_12_17_000004_create_api_environments_table.php
deleted file mode 100644
index 8e45b8f0..00000000
--- a/database/migrations/2025_12_17_000004_create_api_environments_table.php
+++ /dev/null
@@ -1,36 +0,0 @@
-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');
- }
-};
diff --git a/public/js/fcm.js b/public/js/fcm.js
new file mode 100644
index 00000000..7a4c7778
--- /dev/null
+++ b/public/js/fcm.js
@@ -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,
+ };
+
+})();
diff --git a/resources/views/dev-tools/api-explorer/index.blade.php b/resources/views/dev-tools/api-explorer/index.blade.php
index 572395bd..202a9e78 100644
--- a/resources/views/dev-tools/api-explorer/index.blade.php
+++ b/resources/views/dev-tools/api-explorer/index.blade.php
@@ -189,12 +189,23 @@
@endforeach
+
+
+