Commit 00668114 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Move WhatsApp credentials to DB (encrypted) + add Settings UI

- WhatsAppService now reads credentials from messaging_channels table
  (encrypted:array cast) instead of config/env vars
- New MessagingSettings Livewire page to configure credentials from UI
- Webhook verify token also read from DB
- API Keys page auto-pushes MESSAGING_HUB_URL + KEY to instances
  via CapRover env vars on generate/regenerate
- Sidebar shows messaging sub-nav when on messaging pages
- Removed hardcoded whatsapp config from manager.php
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 8984dbc5
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\MessageLog; use App\Models\MessageLog;
use App\Models\MessagingChannel;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Response; use Illuminate\Http\Response;
...@@ -16,9 +17,10 @@ public function verify(Request $request): Response ...@@ -16,9 +17,10 @@ public function verify(Request $request): Response
$token = $request->query('hub_verify_token'); $token = $request->query('hub_verify_token');
$challenge = $request->query('hub_challenge'); $challenge = $request->query('hub_challenge');
$expectedToken = config('manager.whatsapp_webhook_verify_token'); $channel = MessagingChannel::where('name', 'whatsapp')->where('is_active', true)->first();
$expectedToken = $channel?->credentials['webhook_verify_token'] ?? '';
if ($mode === 'subscribe' && $token === $expectedToken) { if ($mode === 'subscribe' && !empty($expectedToken) && $token === $expectedToken) {
return response($challenge, 200)->header('Content-Type', 'text/plain'); return response($challenge, 200)->header('Content-Type', 'text/plain');
} }
......
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
use App\Models\Instance; use App\Models\Instance;
use App\Models\MessagingApiKey; use App\Models\MessagingApiKey;
use App\Services\InstanceProvisionerService;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Livewire\Component; use Livewire\Component;
...@@ -11,21 +12,25 @@ class MessagingKeys extends Component ...@@ -11,21 +12,25 @@ class MessagingKeys extends Component
{ {
public ?int $editingRateLimit = null; public ?int $editingRateLimit = null;
public int $newRateLimit = 100; public int $newRateLimit = 100;
public bool $pushEnvOnGenerate = true;
public function generateKey(int $instanceId): void public function generateKey(int $instanceId): void
{ {
$instance = Instance::findOrFail($instanceId); $instance = Instance::findOrFail($instanceId);
// Delete existing key for this instance
MessagingApiKey::where('instance_id', $instance->id)->delete(); MessagingApiKey::where('instance_id', $instance->id)->delete();
MessagingApiKey::create([ $apiKey = MessagingApiKey::create([
'instance_id' => $instance->id, 'instance_id' => $instance->id,
'api_key' => Str::random(64), 'api_key' => Str::random(64),
'is_active' => true, 'is_active' => true,
'rate_limit_per_hour' => 100, 'rate_limit_per_hour' => 100,
]); ]);
if ($this->pushEnvOnGenerate) {
$this->pushHubEnvToInstance($instance, $apiKey->api_key);
}
session()->flash('success', "API key generated for {$instance->academy_name_ar}"); session()->flash('success', "API key generated for {$instance->academy_name_ar}");
} }
...@@ -43,6 +48,29 @@ public function activateKey(int $keyId): void ...@@ -43,6 +48,29 @@ public function activateKey(int $keyId): void
session()->flash('success', 'API key activated'); session()->flash('success', 'API key activated');
} }
public function regenerateKey(int $keyId): void
{
$key = MessagingApiKey::with('instance')->findOrFail($keyId);
$newApiKey = Str::random(64);
$key->update(['api_key' => $newApiKey, 'is_active' => true, 'messages_sent_today' => 0]);
if ($this->pushEnvOnGenerate && $key->instance) {
$this->pushHubEnvToInstance($key->instance, $newApiKey);
}
session()->flash('success', 'API key regenerated');
}
public function pushEnvManually(int $keyId): void
{
$key = MessagingApiKey::with('instance')->findOrFail($keyId);
if ($key->instance) {
$this->pushHubEnvToInstance($key->instance, $key->api_key);
session()->flash('success', "Env vars pushed to {$key->instance->academy_name_ar}");
}
}
public function startEditRateLimit(int $keyId, int $currentLimit): void public function startEditRateLimit(int $keyId, int $currentLimit): void
{ {
$this->editingRateLimit = $keyId; $this->editingRateLimit = $keyId;
...@@ -71,7 +99,7 @@ public function render() ...@@ -71,7 +99,7 @@ public function render()
->get(); ->get();
$instancesWithoutKeys = Instance::whereDoesntHave('messagingApiKey') $instancesWithoutKeys = Instance::whereDoesntHave('messagingApiKey')
->where('status', 'active') ->whereIn('status', ['active', 'trial'])
->orderBy('academy_name_ar') ->orderBy('academy_name_ar')
->get(['id', 'academy_name_ar', 'app_name']); ->get(['id', 'academy_name_ar', 'app_name']);
...@@ -80,4 +108,17 @@ public function render() ...@@ -80,4 +108,17 @@ public function render()
'instancesWithoutKeys' => $instancesWithoutKeys, 'instancesWithoutKeys' => $instancesWithoutKeys,
])->layout('layouts.app', ['title' => 'API Keys']); ])->layout('layouts.app', ['title' => 'API Keys']);
} }
private function pushHubEnvToInstance(Instance $instance, string $apiKey): void
{
try {
$provisioner = app(InstanceProvisionerService::class);
$provisioner->updateEnvVars($instance, [
'MESSAGING_HUB_URL' => url('/'),
'MESSAGING_HUB_KEY' => $apiKey,
]);
} catch (\Throwable $e) {
session()->flash('warning', "Key created but failed to push env vars: {$e->getMessage()}");
}
}
} }
<?php
namespace App\Livewire;
use App\Models\MessagingChannel;
use App\Services\WhatsAppService;
use Livewire\Component;
class MessagingSettings extends Component
{
public string $phone_number_id = '';
public string $access_token = '';
public string $api_version = 'v25.0';
public string $webhook_verify_token = 'elcaptain_whatsapp_verify_2024';
public bool $is_active = true;
public bool $showToken = false;
public ?string $testResult = null;
public ?string $testError = null;
public function mount(): void
{
$channel = MessagingChannel::where('name', 'whatsapp')->first();
if ($channel) {
$creds = $channel->credentials ?? [];
$this->phone_number_id = $creds['phone_number_id'] ?? '';
$this->access_token = $creds['access_token'] ?? '';
$this->api_version = $creds['api_version'] ?? 'v25.0';
$this->webhook_verify_token = $creds['webhook_verify_token'] ?? 'elcaptain_whatsapp_verify_2024';
$this->is_active = $channel->is_active;
}
}
public function save(): void
{
$this->validate([
'phone_number_id' => 'required|string',
'access_token' => 'required|string',
'api_version' => 'required|string|starts_with:v',
'webhook_verify_token' => 'required|string|min:8',
]);
WhatsAppService::saveCredentials([
'phone_number_id' => $this->phone_number_id,
'access_token' => $this->access_token,
'api_version' => $this->api_version,
'webhook_verify_token' => $this->webhook_verify_token,
'is_active' => $this->is_active,
]);
$this->testResult = null;
$this->testError = null;
session()->flash('success', 'WhatsApp credentials saved successfully.');
}
public function testConnection(): void
{
$this->testResult = null;
$this->testError = null;
$service = app(WhatsAppService::class);
if (!$service->isConfigured()) {
$this->testError = 'Not configured — save credentials first.';
return;
}
$result = $service->sendText('201000000000', 'Test connection from El Captain Manager');
if ($result['success']) {
$this->testResult = 'Connection OK! Message ID: ' . ($result['message_id'] ?? 'N/A');
} else {
$this->testError = 'Failed: ' . ($result['error'] ?? 'Unknown error');
}
}
public function toggleShowToken(): void
{
$this->showToken = !$this->showToken;
}
public function render()
{
$webhookUrl = url('/webhook/whatsapp');
return view('livewire.messaging-settings', [
'webhookUrl' => $webhookUrl,
])->layout('layouts.app', ['title' => 'Messaging Settings']);
}
}
...@@ -2,25 +2,18 @@ ...@@ -2,25 +2,18 @@
namespace App\Services; namespace App\Services;
use App\Models\MessagingChannel;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
class WhatsAppService class WhatsAppService
{ {
private string $apiVersion; private ?array $credentials = null;
private string $phoneNumberId;
private string $accessToken;
public function __construct()
{
$this->apiVersion = config('manager.whatsapp_api_version', 'v25.0');
$this->phoneNumberId = config('manager.whatsapp_phone_number_id', '');
$this->accessToken = config('manager.whatsapp_access_token', '');
}
public function isConfigured(): bool public function isConfigured(): bool
{ {
return !empty($this->phoneNumberId) && !empty($this->accessToken); $creds = $this->getCredentials();
return !empty($creds['phone_number_id']) && !empty($creds['access_token']);
} }
public function sendText(string $to, string $body): array public function sendText(string $to, string $body): array
...@@ -82,12 +75,49 @@ public function sendDocument(string $to, string $documentUrl, ?string $filename ...@@ -82,12 +75,49 @@ public function sendDocument(string $to, string $documentUrl, ?string $filename
]); ]);
} }
public function getCredentials(): array
{
if ($this->credentials !== null) {
return $this->credentials;
}
$channel = MessagingChannel::where('name', 'whatsapp')
->where('is_active', true)
->first();
$this->credentials = $channel?->credentials ?? [];
return $this->credentials;
}
public static function saveCredentials(array $data): MessagingChannel
{
return MessagingChannel::updateOrCreate(
['name' => 'whatsapp'],
[
'provider' => 'meta',
'credentials' => [
'phone_number_id' => $data['phone_number_id'] ?? '',
'access_token' => $data['access_token'] ?? '',
'api_version' => $data['api_version'] ?? 'v25.0',
'webhook_verify_token' => $data['webhook_verify_token'] ?? 'elcaptain_whatsapp_verify_2024',
],
'is_active' => $data['is_active'] ?? true,
]
);
}
private function sendRequest(array $payload): array private function sendRequest(array $payload): array
{ {
$url = "https://graph.facebook.com/{$this->apiVersion}/{$this->phoneNumberId}/messages"; $creds = $this->getCredentials();
$apiVersion = $creds['api_version'] ?? 'v25.0';
$phoneNumberId = $creds['phone_number_id'] ?? '';
$accessToken = $creds['access_token'] ?? '';
$url = "https://graph.facebook.com/{$apiVersion}/{$phoneNumberId}/messages";
try { try {
$response = Http::withToken($this->accessToken) $response = Http::withToken($accessToken)
->timeout(30) ->timeout(30)
->post($url, $payload); ->post($url, $payload);
...@@ -104,12 +134,12 @@ private function sendRequest(array $payload): array ...@@ -104,12 +134,12 @@ private function sendRequest(array $payload): array
return [ return [
'success' => false, 'success' => false,
'error' => $data['error']['message'] ?? 'Unknown error', 'error' => $data['error']['message'] ?? 'Unknown error',
'response' => $data, 'response' => $data ?? [],
]; ];
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error('WhatsApp API Error', [ Log::error('WhatsApp API Error', [
'error' => $e->getMessage(), 'error' => $e->getMessage(),
'payload' => $payload, 'payload_to' => $payload['to'] ?? null,
]); ]);
return [ return [
...@@ -122,15 +152,12 @@ private function sendRequest(array $payload): array ...@@ -122,15 +152,12 @@ private function sendRequest(array $payload): array
private function formatPhone(string $phone): string private function formatPhone(string $phone): string
{ {
// Strip +, spaces, dashes
$phone = preg_replace('/[\s\-\+]/', '', $phone); $phone = preg_replace('/[\s\-\+]/', '', $phone);
// Egyptian numbers: 01x -> 201x
if (str_starts_with($phone, '0')) { if (str_starts_with($phone, '0')) {
$phone = '2' . $phone; $phone = '2' . $phone;
} }
// If starts with 1 (e.g. 1012345678) add 20
if (str_starts_with($phone, '1') && strlen($phone) === 10) { if (str_starts_with($phone, '1') && strlen($phone) === 10) {
$phone = '20' . $phone; $phone = '20' . $phone;
} }
......
...@@ -22,10 +22,4 @@ ...@@ -22,10 +22,4 @@
'billing_day' => env('BILLING_DAY', 1), 'billing_day' => env('BILLING_DAY', 1),
'auto_suspend_enabled' => env('AUTO_SUSPEND_ENABLED', true), 'auto_suspend_enabled' => env('AUTO_SUSPEND_ENABLED', true),
'warning_email_days' => [3, 7], 'warning_email_days' => [3, 7],
// WhatsApp (Meta Graph API)
'whatsapp_api_version' => env('WHATSAPP_API_VERSION', 'v25.0'),
'whatsapp_phone_number_id' => env('WHATSAPP_PHONE_NUMBER_ID', ''),
'whatsapp_access_token' => env('WHATSAPP_ACCESS_TOKEN', ''),
'whatsapp_webhook_verify_token' => env('WHATSAPP_WEBHOOK_VERIFY_TOKEN', 'elcaptain_whatsapp_verify_2024'),
]; ];
...@@ -54,6 +54,14 @@ class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium {{ req ...@@ -54,6 +54,14 @@ class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium {{ req
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
Messaging Messaging
</a> </a>
@if(request()->routeIs('messaging.*'))
<div class="ml-8 space-y-0.5">
<a href="{{ route('messaging.compose') }}" wire:navigate class="block px-3 py-1.5 rounded text-xs {{ request()->routeIs('messaging.compose') ? 'text-white' : 'text-gray-500 hover:text-gray-300' }}">Compose</a>
<a href="{{ route('messaging.log') }}" wire:navigate class="block px-3 py-1.5 rounded text-xs {{ request()->routeIs('messaging.log') ? 'text-white' : 'text-gray-500 hover:text-gray-300' }}">Message Log</a>
<a href="{{ route('messaging.keys') }}" wire:navigate class="block px-3 py-1.5 rounded text-xs {{ request()->routeIs('messaging.keys') ? 'text-white' : 'text-gray-500 hover:text-gray-300' }}">API Keys</a>
<a href="{{ route('messaging.settings') }}" wire:navigate class="block px-3 py-1.5 rounded text-xs {{ request()->routeIs('messaging.settings') ? 'text-white' : 'text-gray-500 hover:text-gray-300' }}">Settings</a>
</div>
@endif
<a href="{{ route('plans.index') }}" wire:navigate <a href="{{ route('plans.index') }}" wire:navigate
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium {{ request()->routeIs('plans.*') ? 'bg-gray-800 text-white' : 'text-gray-400 hover:text-white hover:bg-gray-800' }}"> class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium {{ request()->routeIs('plans.*') ? 'bg-gray-800 text-white' : 'text-gray-400 hover:text-white hover:bg-gray-800' }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>
......
...@@ -104,9 +104,12 @@ class="text-xs text-red-600 dark:text-red-400 hover:underline">Revoke</button> ...@@ -104,9 +104,12 @@ class="text-xs text-red-600 dark:text-red-400 hover:underline">Revoke</button>
<button wire:click="activateKey({{ $key->id }})" <button wire:click="activateKey({{ $key->id }})"
class="text-xs text-green-600 dark:text-green-400 hover:underline">Activate</button> class="text-xs text-green-600 dark:text-green-400 hover:underline">Activate</button>
@endif @endif
<button wire:click="generateKey({{ $key->instance_id }})" <button wire:click="regenerateKey({{ $key->id }})"
wire:confirm="This will replace the existing key. Continue?" wire:confirm="This will replace the existing key. Continue?"
class="text-xs text-blue-600 dark:text-blue-400 hover:underline">Regenerate</button> class="text-xs text-blue-600 dark:text-blue-400 hover:underline">Regenerate</button>
<button wire:click="pushEnvManually({{ $key->id }})"
title="Push MESSAGING_HUB_URL + KEY to this instance"
class="text-xs text-purple-600 dark:text-purple-400 hover:underline">Push Env</button>
</div> </div>
</td> </td>
</tr> </tr>
......
<div class="max-w-3xl mx-auto px-4 sm:px-6 py-8 space-y-8">
{{-- Flash --}}
@if(session('success'))
<div class="p-4 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg text-green-700 dark:text-green-300 text-sm">
{{ session('success') }}
</div>
@endif
{{-- WhatsApp Credentials --}}
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-800">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">WhatsApp API Credentials</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Meta Graph API configuration. All credentials are encrypted at rest.</p>
</div>
<form wire:submit="save" class="px-6 py-5 space-y-5">
{{-- Phone Number ID --}}
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Phone Number ID</label>
<input type="text" wire:model="phone_number_id"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="954525434419314">
@error('phone_number_id') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
{{-- Access Token --}}
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Access Token</label>
<div class="relative">
<input type="{{ $showToken ? 'text' : 'password' }}" wire:model="access_token"
class="w-full px-3 py-2 pr-20 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm font-mono focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="EAAdfq4ZBXBZBo...">
<button type="button" wire:click="toggleShowToken"
class="absolute inset-y-0 right-0 px-3 text-xs font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
{{ $showToken ? 'Hide' : 'Show' }}
</button>
</div>
@error('access_token') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
{{-- API Version + Verify Token --}}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">API Version</label>
<input type="text" wire:model="api_version"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="v25.0">
@error('api_version') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Webhook Verify Token</label>
<input type="text" wire:model="webhook_verify_token"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="elcaptain_whatsapp_verify_2024">
@error('webhook_verify_token') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
{{-- Active Toggle --}}
<div class="flex items-center gap-3">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" wire:model="is_active" class="sr-only peer">
<div class="w-10 h-5 bg-gray-300 dark:bg-gray-700 peer-focus:ring-2 peer-focus:ring-blue-500 rounded-full peer peer-checked:bg-green-500 transition-colors"></div>
<div class="absolute left-0.5 top-0.5 w-4 h-4 bg-white rounded-full peer-checked:translate-x-5 transition-transform"></div>
</label>
<span class="text-sm text-gray-700 dark:text-gray-300">Channel Active</span>
</div>
{{-- Actions --}}
<div class="flex items-center gap-3 pt-2">
<button type="submit"
wire:loading.attr="disabled"
class="px-5 py-2.5 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50 transition">
<span wire:loading.remove wire:target="save">Save Credentials</span>
<span wire:loading wire:target="save">Saving...</span>
</button>
<button type="button" wire:click="testConnection"
wire:loading.attr="disabled"
class="px-5 py-2.5 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-lg text-sm font-medium hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 transition">
<span wire:loading.remove wire:target="testConnection">Test Connection</span>
<span wire:loading wire:target="testConnection">Testing...</span>
</button>
</div>
{{-- Test Result --}}
@if($testResult)
<div class="p-3 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg text-green-700 dark:text-green-300 text-sm">
{{ $testResult }}
</div>
@endif
@if($testError)
<div class="p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-300 text-sm">
{{ $testError }}
</div>
@endif
</form>
</div>
{{-- Webhook Info --}}
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-800">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Webhook Configuration</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Use these values in the Meta Developer Dashboard.</p>
</div>
<div class="px-6 py-5 space-y-4">
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Callback URL</label>
<div class="flex items-center gap-2" x-data="{ copied: false }">
<code class="flex-1 px-3 py-2 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg text-sm text-gray-800 dark:text-gray-200 font-mono truncate">{{ $webhookUrl }}</code>
<button @click="navigator.clipboard.writeText('{{ $webhookUrl }}'); copied = true; setTimeout(() => copied = false, 2000)"
class="px-3 py-2 text-xs font-medium bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition whitespace-nowrap">
<span x-show="!copied">Copy</span>
<span x-show="copied" x-cloak class="text-green-600 dark:text-green-400">Copied!</span>
</button>
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Verify Token</label>
<div class="flex items-center gap-2" x-data="{ copied: false }">
<code class="flex-1 px-3 py-2 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg text-sm text-gray-800 dark:text-gray-200 font-mono truncate">{{ $webhook_verify_token }}</code>
<button @click="navigator.clipboard.writeText('{{ $webhook_verify_token }}'); copied = true; setTimeout(() => copied = false, 2000)"
class="px-3 py-2 text-xs font-medium bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition whitespace-nowrap">
<span x-show="!copied">Copy</span>
<span x-show="copied" x-cloak class="text-green-600 dark:text-green-400">Copied!</span>
</button>
</div>
</div>
<p class="text-xs text-amber-600 dark:text-amber-400">
<svg class="w-4 h-4 inline-block mr-1" 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-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"/></svg>
Make sure the manager app is accessible from the internet for Meta to reach the webhook.
</p>
</div>
</div>
</div>
...@@ -15,6 +15,7 @@ ...@@ -15,6 +15,7 @@
use App\Livewire\MessagingDashboard; use App\Livewire\MessagingDashboard;
use App\Livewire\MessagingKeys; use App\Livewire\MessagingKeys;
use App\Livewire\MessagingLog; use App\Livewire\MessagingLog;
use App\Livewire\MessagingSettings;
use App\Livewire\PlanList; use App\Livewire\PlanList;
use App\Livewire\RecordPayment; use App\Livewire\RecordPayment;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
...@@ -48,6 +49,7 @@ ...@@ -48,6 +49,7 @@
Route::get('/messaging/compose', MessagingCompose::class)->name('messaging.compose'); Route::get('/messaging/compose', MessagingCompose::class)->name('messaging.compose');
Route::get('/messaging/log', MessagingLog::class)->name('messaging.log'); Route::get('/messaging/log', MessagingLog::class)->name('messaging.log');
Route::get('/messaging/keys', MessagingKeys::class)->name('messaging.keys'); Route::get('/messaging/keys', MessagingKeys::class)->name('messaging.keys');
Route::get('/messaging/settings', MessagingSettings::class)->name('messaging.settings');
}); });
// Client portal — public, authenticated by instance UUID token // Client portal — public, authenticated by instance UUID token
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment