From 43b8e93d6d68b791d3d52e2a5ab31e799f8469f6 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Thu, 16 Apr 2026 20:54:43 +0300 Subject: [PATCH] fix: properly handle WhatsApp config save/corruption/reset flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the persistent 'Stored access token is corrupted' error: handleSave was writing the access_token DIRECTLY to Supabase via the browser client — WITHOUT calling the server-side POST /api/whatsapp/config handler. The server handler is what actually encrypts the token with ENCRYPTION_KEY. So every save stored the token in PLAINTEXT, and every subsequent decrypt attempt threw 'corrupted'. This meant no amount of re-saving could ever fix it — each save re-introduced plaintext data that would fail decryption again. Changes in src/components/settings/whatsapp-config.tsx: - handleSave now POSTs to /api/whatsapp/config (server verifies with Meta AND encrypts the token before storing). - On page load, after pulling form values from Supabase, we also call GET /api/whatsapp/config to check the config's health. If the health check reports needs_reset (decrypt failure), we show a prominent amber banner with a "Reset Configuration" button. - Added handleReset that calls DELETE /api/whatsapp/config to clear the broken row and reset local state. - Added a "Reset Configuration" button in the main action row (available whenever a config exists, for manual cleanup). - Better error messages — shows the actual server-side reason (token_corrupted, meta_api_error, no_config). - Save flow now requires re-entering the access token when updating (can't update without a token to verify against Meta). Changes in src/app/api/whatsapp/config/route.ts: - GET now returns 200 with { connected, reason, message, needs_reset } instead of 500 on decrypt failure. The UI can handle this gracefully. - Added DELETE handler to remove the user's config row. - POST also surfaces encryption errors (e.g. missing ENCRYPTION_KEY). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/whatsapp/config/route.ts | 131 +++++++--- src/components/settings/whatsapp-config.tsx | 273 +++++++++++++++----- 2 files changed, 309 insertions(+), 95 deletions(-) diff --git a/src/app/api/whatsapp/config/route.ts b/src/app/api/whatsapp/config/route.ts index 6e87ece..52d8721 100644 --- a/src/app/api/whatsapp/config/route.ts +++ b/src/app/api/whatsapp/config/route.ts @@ -6,10 +6,15 @@ import { encrypt, decrypt } from '@/lib/whatsapp/encryption' /** * GET /api/whatsapp/config * - * Used by the "Test API Connection" button in Settings. - * Fetches the user's saved config, decrypts the access token, and pings - * Meta to verify the credentials still work. Returns the phone info - * (display_phone_number, verified_name, quality_rating) on success. + * Used by the "Test API Connection" button and by the page to check + * whether the saved config is healthy. Returns 200 in all non-auth cases + * so the UI can render an appropriate message rather than show a 500. + * + * Response shape: + * { connected: true, phone_info: {...} } + * { connected: false, reason: 'no_config', message: '...' } + * { connected: false, reason: 'token_corrupted', message: '...', needs_reset: true } + * { connected: false, reason: 'meta_api_error', message: '...' } */ export async function GET() { try { @@ -33,50 +38,66 @@ export async function GET() { if (configError) { console.error('Error fetching whatsapp_config:', configError) return NextResponse.json( - { error: 'Failed to fetch configuration' }, - { status: 500 } + { connected: false, reason: 'db_error', message: 'Failed to fetch configuration' }, + { status: 200 } ) } if (!config) { return NextResponse.json( - { connected: false, error: 'No WhatsApp configuration saved yet' }, - { status: 404 } + { + connected: false, + reason: 'no_config', + message: 'No WhatsApp configuration saved yet. Fill in the form and click Save Configuration.', + }, + { status: 200 } ) } - // Decrypt access token and call Meta to verify it still works + // Try to decrypt the stored token with the current ENCRYPTION_KEY. + // If this fails, the key changed (or was never consistent across envs). let accessToken: string try { accessToken = decrypt(config.access_token) } catch (err) { - console.error('Failed to decrypt access token:', err) + console.error('[whatsapp/config GET] Token decryption failed:', err) return NextResponse.json( - { connected: false, error: 'Stored access token is corrupted — please re-save configuration' }, - { status: 500 } + { + connected: false, + reason: 'token_corrupted', + needs_reset: true, + message: + 'The stored access token cannot be decrypted with the current ENCRYPTION_KEY. This usually means the key changed, or it differs between environments (local vs Hostinger vs Vercel). Click "Reset Configuration" below, then re-save.', + }, + { status: 200 } ) } + // Validate credentials against Meta try { const phoneInfo = await verifyPhoneNumber( config.phone_number_id, accessToken ) - return NextResponse.json({ - connected: true, - phone_info: phoneInfo, - }) + return NextResponse.json({ connected: true, phone_info: phoneInfo }) } catch (err) { const message = err instanceof Error ? err.message : 'Unknown Meta API error' - console.error('Meta API verification failed:', message) + console.error('[whatsapp/config GET] Meta API verification failed:', message) return NextResponse.json( - { connected: false, error: message }, - { status: 502 } + { + connected: false, + reason: 'meta_api_error', + message: `Meta API rejected the credentials: ${message}`, + }, + { status: 200 } ) } } catch (error) { console.error('Error in WhatsApp config GET:', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + return NextResponse.json( + { connected: false, reason: 'unknown', message: 'Internal server error' }, + { status: 500 } + ) } } @@ -84,8 +105,7 @@ export async function GET() { * POST /api/whatsapp/config * * Saves or updates the WhatsApp config for the authenticated user. - * Verifies the credentials with Meta before saving. Tokens are encrypted - * at rest with AES-256-CBC. + * Verifies credentials with Meta first, then encrypts and stores. */ export async function POST(request: Request) { try { @@ -111,7 +131,7 @@ export async function POST(request: Request) { } // Verify credentials with Meta BEFORE saving - // (function signature is verifyPhoneNumber(phoneNumberId, accessToken)) + // Signature: verifyPhoneNumber(phoneNumberId, accessToken) let phoneInfo try { phoneInfo = await verifyPhoneNumber(phone_number_id, access_token) @@ -125,10 +145,24 @@ export async function POST(request: Request) { } // Encrypt sensitive tokens before storing - const encryptedAccessToken = encrypt(access_token) - const encryptedVerifyToken = verify_token ? encrypt(verify_token) : null + let encryptedAccessToken: string + let encryptedVerifyToken: string | null + try { + encryptedAccessToken = encrypt(access_token) + encryptedVerifyToken = verify_token ? encrypt(verify_token) : null + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown encryption error' + console.error('Encryption failed:', message) + return NextResponse.json( + { + error: + 'Failed to encrypt token. Check that ENCRYPTION_KEY is a valid 64-character hex string in your environment variables.', + }, + { status: 500 } + ) + } - // Upsert + // Upsert — overwrite any existing (possibly corrupted) config const { data: existing } = await supabase .from('whatsapp_config') .select('id') @@ -178,12 +212,49 @@ export async function POST(request: Request) { } } - return NextResponse.json({ - success: true, - phone_info: phoneInfo, - }) + return NextResponse.json({ success: true, phone_info: phoneInfo }) } catch (error) { console.error('Error in WhatsApp config POST:', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } + +/** + * DELETE /api/whatsapp/config + * + * Removes the authenticated user's WhatsApp configuration row. + * Used by the "Reset Configuration" button to recover from a corrupted + * encrypted token (mismatched ENCRYPTION_KEY across environments). + */ +export async function DELETE() { + try { + const supabase = await createClient() + + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser() + + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { error: deleteError } = await supabase + .from('whatsapp_config') + .delete() + .eq('user_id', user.id) + + if (deleteError) { + console.error('Error deleting whatsapp_config:', deleteError) + return NextResponse.json( + { error: 'Failed to delete configuration' }, + { status: 500 } + ) + } + + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Error in WhatsApp config DELETE:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/src/components/settings/whatsapp-config.tsx b/src/components/settings/whatsapp-config.tsx index fc63253..963ee80 100644 --- a/src/components/settings/whatsapp-config.tsx +++ b/src/components/settings/whatsapp-config.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useCallback } from 'react'; import { toast } from 'sonner'; import { Eye, @@ -11,6 +11,8 @@ import { Loader2, ExternalLink, Zap, + AlertTriangle, + RotateCcw, } from 'lucide-react'; import { createClient } from '@/lib/supabase/client'; import { useAuth } from '@/hooks/use-auth'; @@ -29,6 +31,9 @@ import type { WhatsAppConfig as WhatsAppConfigType } from '@/types'; const MASKED_TOKEN = '••••••••••••••••'; +type ConnectionStatus = 'connected' | 'disconnected' | 'unknown'; +type ResetReason = 'token_corrupted' | 'meta_api_error' | null; + export function WhatsAppConfig() { const supabase = createClient(); const { user, loading: authLoading } = useAuth(); @@ -36,9 +41,12 @@ export function WhatsAppConfig() { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [testing, setTesting] = useState(false); + const [resetting, setResetting] = useState(false); const [showToken, setShowToken] = useState(false); const [config, setConfig] = useState(null); - const [connectionStatus, setConnectionStatus] = useState<'connected' | 'disconnected' | 'unknown'>('unknown'); + const [connectionStatus, setConnectionStatus] = useState('unknown'); + const [resetReason, setResetReason] = useState(null); + const [statusMessage, setStatusMessage] = useState(''); const [phoneNumberId, setPhoneNumberId] = useState(''); const [wabaId, setWabaId] = useState(''); @@ -51,28 +59,18 @@ export function WhatsAppConfig() { ? `${window.location.origin}/api/whatsapp/webhook` : ''; - useEffect(() => { - if (authLoading) return; - if (!user) { - setLoading(false); - return; - } - fetchConfig(user.id); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [authLoading, user?.id]); - - async function fetchConfig(userId: string) { + const fetchConfig = useCallback(async (userId: string) => { + setLoading(true); try { - setLoading(true); - + // Load form values from Supabase (shows what's in DB) const { data, error } = await supabase .from('whatsapp_config') .select('*') .eq('user_id', userId) - .single(); + .maybeSingle(); - if (error && error.code !== 'PGRST116') { - throw error; + if (error) { + console.error('Failed to load config row:', error); } if (data) { @@ -80,65 +78,113 @@ export function WhatsAppConfig() { setPhoneNumberId(data.phone_number_id || ''); setWabaId(data.waba_id || ''); setAccessToken(MASKED_TOKEN); - setVerifyToken(data.verify_token || ''); - setConnectionStatus(data.status || 'disconnected'); + setVerifyToken(''); + setTokenEdited(false); + } else { + setConfig(null); + setPhoneNumberId(''); + setWabaId(''); + setAccessToken(''); + setVerifyToken(''); + setTokenEdited(false); + } + + // Then verify health via the API (decrypts token + pings Meta) + if (data) { + try { + const res = await fetch('/api/whatsapp/config', { method: 'GET' }); + const payload = await res.json(); + + if (payload.connected) { + setConnectionStatus('connected'); + setResetReason(null); + setStatusMessage(''); + } else { + setConnectionStatus('disconnected'); + setResetReason(payload.needs_reset ? 'token_corrupted' : payload.reason === 'meta_api_error' ? 'meta_api_error' : null); + setStatusMessage(payload.message || ''); + } + } catch (err) { + console.error('Health check failed:', err); + setConnectionStatus('disconnected'); + } + } else { + setConnectionStatus('disconnected'); + setResetReason(null); + setStatusMessage(''); } } catch (err) { - console.error('Failed to fetch config:', err); + console.error('fetchConfig error:', err); toast.error('Failed to load WhatsApp configuration'); } finally { setLoading(false); } - } + }, [supabase]); + + useEffect(() => { + if (authLoading) return; + if (!user) { + setLoading(false); + return; + } + fetchConfig(user.id); + }, [authLoading, user, fetchConfig]); async function handleSave() { if (!phoneNumberId.trim()) { toast.error('Phone Number ID is required'); return; } - if (!tokenEdited && !config) { - toast.error('Access Token is required'); + if (!config && (!accessToken.trim() || !tokenEdited)) { + toast.error('Access Token is required for initial setup'); return; } try { setSaving(true); - if (!user) { - toast.error('Not authenticated'); - return; - } + // Always POST through the API — it verifies with Meta and encrypts + // the access_token server-side with ENCRYPTION_KEY. Skipping this + // and writing direct to Supabase stores the token in plaintext, + // which then fails decryption on every subsequent health check. const payload: Record = { - user_id: user.id, phone_number_id: phoneNumberId.trim(), waba_id: wabaId.trim() || null, verify_token: verifyToken.trim() || null, }; - if (tokenEdited && accessToken !== MASKED_TOKEN) { + if (tokenEdited && accessToken !== MASKED_TOKEN && accessToken.trim()) { payload.access_token = accessToken.trim(); + } else if (config) { + // Existing config — reuse stored encrypted token by decrypting on the + // server. But our POST handler requires an access_token to verify + // with Meta. If the user didn't change the token, we need to signal + // that. Simplest: require token re-entry if they're updating. + toast.error('Please re-enter the Access Token to save changes'); + setSaving(false); + return; } - if (config?.id) { - const { error } = await supabase - .from('whatsapp_config') - .update(payload) - .eq('id', config.id); - if (error) throw error; - } else { - if (!tokenEdited || !accessToken.trim() || accessToken === MASKED_TOKEN) { - toast.error('Access Token is required for initial setup'); - return; - } - payload.access_token = accessToken.trim(); - payload.status = 'disconnected'; - const { error } = await supabase - .from('whatsapp_config') - .insert(payload); - if (error) throw error; + const res = await fetch('/api/whatsapp/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + const data = await res.json(); + + if (!res.ok) { + toast.error(data.error || 'Failed to save configuration'); + setSaving(false); + return; } - toast.success('Configuration saved successfully'); + toast.success( + data.phone_info?.verified_name + ? `Connected to ${data.phone_info.verified_name}` + : 'Configuration saved successfully' + ); + if (user) await fetchConfig(user.id); } catch (err) { console.error('Save error:', err); @@ -151,32 +197,66 @@ export function WhatsAppConfig() { async function handleTestConnection() { try { setTesting(true); - const response = await fetch('/api/whatsapp/config', { - method: 'GET', - }); + const res = await fetch('/api/whatsapp/config', { method: 'GET' }); + const payload = await res.json(); - if (response.ok) { + if (payload.connected) { setConnectionStatus('connected'); - toast.success('API connection successful'); - - if (config?.id) { - await supabase - .from('whatsapp_config') - .update({ status: 'connected', connected_at: new Date().toISOString() }) - .eq('id', config.id); - } + setResetReason(null); + setStatusMessage(''); + toast.success( + payload.phone_info?.verified_name + ? `Connected to ${payload.phone_info.verified_name}` + : 'API connection successful' + ); } else { setConnectionStatus('disconnected'); - toast.error('API connection failed. Check your credentials.'); + setResetReason(payload.needs_reset ? 'token_corrupted' : payload.reason === 'meta_api_error' ? 'meta_api_error' : null); + setStatusMessage(payload.message || ''); + toast.error(payload.message || 'API connection failed'); } - } catch { + } catch (err) { + console.error('Test connection error:', err); setConnectionStatus('disconnected'); - toast.error('Connection test failed. Ensure your server is running.'); + toast.error('Connection test failed. Check network and try again.'); } finally { setTesting(false); } } + async function handleReset() { + if (!confirm('This will delete the current WhatsApp config so you can re-enter it. Continue?')) { + return; + } + + try { + setResetting(true); + const res = await fetch('/api/whatsapp/config', { method: 'DELETE' }); + const data = await res.json(); + + if (!res.ok) { + toast.error(data.error || 'Failed to reset configuration'); + return; + } + + toast.success('Configuration cleared. You can now re-enter your credentials.'); + setConfig(null); + setPhoneNumberId(''); + setWabaId(''); + setAccessToken(''); + setVerifyToken(''); + setTokenEdited(false); + setConnectionStatus('disconnected'); + setResetReason(null); + setStatusMessage(''); + } catch (err) { + console.error('Reset error:', err); + toast.error('Failed to reset configuration'); + } finally { + setResetting(false); + } + } + function handleCopyWebhookUrl() { navigator.clipboard.writeText(webhookUrl); toast.success('Webhook URL copied to clipboard'); @@ -190,10 +270,47 @@ export function WhatsAppConfig() { ); } + const showResetBanner = resetReason === 'token_corrupted'; + return (
{/* Main config form */}
+ {/* Corrupted-token reset banner */} + {showResetBanner && ( + +
+ +
+ + Stored token can't be decrypted + + + {statusMessage} + + +
+
+
+ )} + {/* Connection Status */}
@@ -209,7 +326,8 @@ export function WhatsAppConfig() { {connectionStatus === 'connected' ? 'Your WhatsApp Business API is connected and ready to send/receive messages.' - : 'Configure your Meta API credentials below to connect your WhatsApp Business account.'} + : statusMessage || + 'Configure your Meta API credentials below to connect your WhatsApp Business account.'} @@ -269,6 +387,11 @@ export function WhatsAppConfig() { {showToken ? : }
+ {config && !tokenEdited && ( +

+ Token is hidden for security. Re-enter it to update configuration. +

+ )}
@@ -317,7 +440,7 @@ export function WhatsAppConfig() { {/* Action Buttons */} -
+
+ {config && ( + + )}