diff --git a/src/app/api/whatsapp/config/route.ts b/src/app/api/whatsapp/config/route.ts index 399699c4ed..8db34f1d22 100644 --- a/src/app/api/whatsapp/config/route.ts +++ b/src/app/api/whatsapp/config/route.ts @@ -1,5 +1,4 @@ import { NextResponse } from 'next/server' -import { createClient } from '@/lib/supabase/server' import { createClient as createAdminClient } from '@supabase/supabase-js' import { registerPhoneNumber, @@ -7,29 +6,9 @@ import { verifyPhoneNumber, } from '@/lib/whatsapp/meta-api' import { encrypt, decrypt } from '@/lib/whatsapp/encryption' +import { getCurrentAccount, requireRole, toErrorResponse } from '@/lib/auth/account' + -/** - * Resolve the caller's account_id from their profile. Inlined here - * (rather than going through `@/lib/auth/account.getCurrentAccount`) - * because the GET handler wants to return shaped 200s for every - * non-auth failure mode, not throw — keeping the helper minimal lets - * the existing response branches stay as-is. - * - * Returns null if the user has no profile or no account; callers - * should treat that the same as "not connected". - */ -async function resolveAccountId( - supabase: Awaited>, - userId: string, -): Promise { - const { data, error } = await supabase - .from('profiles') - .select('account_id') - .eq('user_id', userId) - .maybeSingle() - if (error || !data?.account_id) return null - return data.account_id as string -} // Lazy-initialised service-role client. We need it to detect a // phone_number_id already claimed by a *different* user — under RLS, @@ -60,30 +39,11 @@ function supabaseAdmin() { * { connected: false, reason: 'token_corrupted', message: '...', needs_reset: true } * { connected: false, reason: 'meta_api_error', message: '...' } */ -export async function GET() { +export async function GET(request: Request) { 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 accountId = await resolveAccountId(supabase, user.id) - if (!accountId) { - return NextResponse.json( - { - connected: false, - reason: 'no_account', - message: 'Your profile is not linked to an account.', - }, - { status: 200 }, - ) - } + const { searchParams } = new URL(request.url) + const quick = searchParams.get('quick') === 'true' + const { supabase, accountId } = await getCurrentAccount() const { data: config, error: configError } = await supabase .from('whatsapp_config') @@ -129,6 +89,11 @@ export async function GET() { ) } + // If quick validation requested, return connected status early to save Meta API rate limits + if (quick) { + return NextResponse.json({ connected: config.status === 'connected', quick: true }) + } + // Validate credentials against Meta try { const phoneInfo = await verifyPhoneNumber({ @@ -150,10 +115,7 @@ export async function GET() { } } catch (error) { console.error('Error in WhatsApp config GET:', error) - return NextResponse.json( - { connected: false, reason: 'unknown', message: 'Internal server error' }, - { status: 500 } - ) + return toErrorResponse(error) } } @@ -165,24 +127,7 @@ export async function GET() { */ export async function POST(request: Request) { 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 accountId = await resolveAccountId(supabase, user.id) - if (!accountId) { - return NextResponse.json( - { error: 'Your profile is not linked to an account.' }, - { status: 403 }, - ) - } + const { supabase, accountId, userId } = await requireRole('admin') const body = await request.json() const { phone_number_id, waba_id, access_token, verify_token, pin } = body @@ -388,7 +333,7 @@ export async function POST(request: Request) { .from('whatsapp_config') .insert({ account_id: accountId, - user_id: user.id, + user_id: userId, ...baseRow, }) @@ -427,7 +372,7 @@ export async function POST(request: Request) { }) } catch (error) { console.error('Error in WhatsApp config POST:', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + return toErrorResponse(error) } } @@ -440,24 +385,7 @@ export async function POST(request: Request) { */ 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 accountId = await resolveAccountId(supabase, user.id) - if (!accountId) { - return NextResponse.json( - { error: 'Your profile is not linked to an account.' }, - { status: 403 }, - ) - } + const { supabase, accountId } = await requireRole('admin') const { error: deleteError } = await supabase .from('whatsapp_config') @@ -475,6 +403,6 @@ export async function DELETE() { return NextResponse.json({ success: true }) } catch (error) { console.error('Error in WhatsApp config DELETE:', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + return toErrorResponse(error) } } diff --git a/src/app/api/whatsapp/config/verify-registration/route.ts b/src/app/api/whatsapp/config/verify-registration/route.ts index 3dea7210aa..a11287daaa 100644 --- a/src/app/api/whatsapp/config/verify-registration/route.ts +++ b/src/app/api/whatsapp/config/verify-registration/route.ts @@ -1,10 +1,10 @@ import { NextResponse } from 'next/server' -import { createClient } from '@/lib/supabase/server' import { decrypt } from '@/lib/whatsapp/encryption' import { getSubscribedApps, verifyPhoneNumber, } from '@/lib/whatsapp/meta-api' +import { requireRole, toErrorResponse } from '@/lib/auth/account' /** * GET /api/whatsapp/config/verify-registration @@ -29,128 +29,109 @@ import { * what the UI badges on. */ export async function GET() { - const supabase = await createClient() - const { - data: { user }, - error: authError, - } = await supabase.auth.getUser() - if (authError || !user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // whatsapp_config is one-row-per-account post-017. Resolve the - // caller's account_id so a teammate who joined an existing account - // sees the same registration state as the admin who set it up. - const { data: profile } = await supabase - .from('profiles') - .select('account_id') - .eq('user_id', user.id) - .maybeSingle() - const accountId = profile?.account_id as string | undefined - if (!accountId) { - return NextResponse.json({ - live: false, - checks: { config_exists: false }, - message: 'Your profile is not linked to an account.', - }) - } + try { + const { supabase, accountId } = await requireRole('admin') - const { data: config } = await supabase - .from('whatsapp_config') - .select('*') - .eq('account_id', accountId) - .maybeSingle() + const { data: config } = await supabase + .from('whatsapp_config') + .select('*') + .eq('account_id', accountId) + .maybeSingle() - if (!config) { - return NextResponse.json({ - live: false, - checks: { config_exists: false }, - message: 'No WhatsApp configuration saved yet.', - }) - } - - let accessToken: string - try { - accessToken = decrypt(config.access_token) - } catch { - return NextResponse.json({ - live: false, - checks: { - config_exists: true, - token_decryptable: false, - }, - message: - 'Stored access token can\'t be decrypted — likely ENCRYPTION_KEY changed. Re-enter the token to repair.', - }) - } + if (!config) { + return NextResponse.json({ + live: false, + checks: { config_exists: false }, + message: 'No WhatsApp configuration saved yet.', + }) + } - const checks: { - config_exists: boolean - token_decryptable: boolean - phone_metadata_ok: boolean - waba_subscribed_to_app: boolean | null - locally_marked_registered: boolean - } = { - config_exists: true, - token_decryptable: true, - phone_metadata_ok: false, - waba_subscribed_to_app: null, - locally_marked_registered: config.registered_at != null, - } - const errors: string[] = [] + let accessToken: string + try { + accessToken = decrypt(config.access_token) + } catch { + return NextResponse.json({ + live: false, + checks: { + config_exists: true, + token_decryptable: false, + }, + message: + 'Stored access token can\'t be decrypted — likely ENCRYPTION_KEY changed. Re-enter the token to repair.', + }) + } - // 1. Phone metadata - try { - await verifyPhoneNumber({ - phoneNumberId: config.phone_number_id, - accessToken, - }) - checks.phone_metadata_ok = true - } catch (err) { - errors.push( - `Phone metadata check failed: ${err instanceof Error ? err.message : String(err)}`, - ) - } + const checks: { + config_exists: boolean + token_decryptable: boolean + phone_metadata_ok: boolean + waba_subscribed_to_app: boolean | null + locally_marked_registered: boolean + } = { + config_exists: true, + token_decryptable: true, + phone_metadata_ok: false, + waba_subscribed_to_app: null, + locally_marked_registered: config.registered_at != null, + } + const errors: string[] = [] - // 2. WABA subscription — only meaningful if we have a waba_id - if (config.waba_id) { + // 1. Phone metadata try { - const subs = await getSubscribedApps({ - wabaId: config.waba_id, + await verifyPhoneNumber({ + phoneNumberId: config.phone_number_id, accessToken, }) - // Meta returns the apps subscribed to this WABA. If the list - // is non-empty, OUR app is in there (the access_token we used - // belongs to our app — Meta wouldn't return data for an app - // the token can't see). Treat any entry as success. - checks.waba_subscribed_to_app = subs.length > 0 - if (!checks.waba_subscribed_to_app) { + checks.phone_metadata_ok = true + } catch (err) { + errors.push( + `Phone metadata check failed: ${err instanceof Error ? err.message : String(err)}`, + ) + } + + // 2. WABA subscription — only meaningful if we have a waba_id + if (config.waba_id) { + try { + const subs = await getSubscribedApps({ + wabaId: config.waba_id, + accessToken, + }) + // Meta returns the apps subscribed to this WABA. If the list + // is non-empty, OUR app is in there (the access_token we used + // belongs to our app — Meta wouldn't return data for an app + // the token can't see). Treat any entry as success. + checks.waba_subscribed_to_app = subs.length > 0 + if (!checks.waba_subscribed_to_app) { + errors.push( + 'WABA has no subscribed apps. Re-save the configuration to subscribe.', + ) + } + } catch (err) { errors.push( - 'WABA has no subscribed apps. Re-save the configuration to subscribe.', + `WABA subscription check failed: ${err instanceof Error ? err.message : String(err)}`, ) } - } catch (err) { + } else { errors.push( - `WABA subscription check failed: ${err instanceof Error ? err.message : String(err)}`, + 'No WABA ID on file — webhooks can\'t be wired without it. Add it in the form and re-save.', ) } - } else { - errors.push( - 'No WABA ID on file — webhooks can\'t be wired without it. Add it in the form and re-save.', - ) - } - const live = - checks.phone_metadata_ok && - (checks.waba_subscribed_to_app ?? false) && - checks.locally_marked_registered + const live = + checks.phone_metadata_ok && + (checks.waba_subscribed_to_app ?? false) && + checks.locally_marked_registered - return NextResponse.json({ - live, - checks, - errors, - last_registration_error: config.last_registration_error ?? null, - registered_at: config.registered_at ?? null, - subscribed_apps_at: config.subscribed_apps_at ?? null, - }) + return NextResponse.json({ + live, + checks, + errors, + last_registration_error: config.last_registration_error ?? null, + registered_at: config.registered_at ?? null, + subscribed_apps_at: config.subscribed_apps_at ?? null, + }) + } catch (error) { + console.error('Error in WhatsApp verify-registration GET:', error) + return toErrorResponse(error) + } } diff --git a/src/components/settings/template-manager.tsx b/src/components/settings/template-manager.tsx index 827cb12496..c96f3e3b87 100644 --- a/src/components/settings/template-manager.tsx +++ b/src/components/settings/template-manager.tsx @@ -12,6 +12,8 @@ import { Pencil, RotateCcw, Upload, + Eye, + Copy, } from 'lucide-react'; import { createClient } from '@/lib/supabase/client'; import { @@ -127,11 +129,12 @@ function emptyButton(type: TemplateButton['type']): TemplateButton { export function TemplateManager() { const t = useTranslations('Settings.templates'); const supabase = createClient(); - const { user, loading: authLoading } = useAuth(); + const { user, accountId, canManageTemplates, loading: authLoading } = useAuth(); const [loading, setLoading] = useState(true); const [templates, setTemplates] = useState([]); const [dialogOpen, setDialogOpen] = useState(false); + const [viewingOnly, setViewingOnly] = useState(false); const [submitting, setSubmitting] = useState(false); const [syncing, setSyncing] = useState(false); const [form, setForm] = useState(emptyForm); @@ -179,21 +182,21 @@ export function TemplateManager() { useEffect(() => { if (authLoading) return; - if (!user) { + if (!accountId) { setLoading(false); return; } - fetchTemplates(user.id); + fetchTemplates(accountId); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [authLoading, user?.id]); + }, [authLoading, accountId]); - async function fetchTemplates(userId: string) { + async function fetchTemplates(accountId: string) { try { setLoading(true); const { data, error } = await supabase .from('message_templates') .select('*') - .eq('user_id', userId) + .eq('account_id', accountId) .order('created_at', { ascending: false }); if (error) throw error; setTemplates(data || []); @@ -235,6 +238,7 @@ export function TemplateManager() { function openEdit(template: MessageTemplate) { setEditingId(template.id); + setViewingOnly(false); setForm({ name: template.name, category: template.category, @@ -251,8 +255,51 @@ export function TemplateManager() { setDialogOpen(true); } + function openView(template: MessageTemplate) { + setEditingId(template.id); + setViewingOnly(true); + setForm({ + name: template.name, + category: template.category, + language: template.language || 'en_US', + header_format: (template.header_type ?? 'none') as HeaderFormat, + header_content: template.header_content ?? '', + header_media_url: template.header_media_url ?? '', + header_sample: template.sample_values?.header?.[0] ?? '', + body_text: template.body_text, + body_samples: template.sample_values?.body ?? [], + footer_text: template.footer_text ?? '', + buttons: template.buttons ?? [], + }); + setDialogOpen(true); + } + + function openClone(template: MessageTemplate) { + setEditingId(null); + setViewingOnly(false); + + let cloneName = template.name + '_copy'; + cloneName = cloneName.toLowerCase().replace(/[^a-z0-9_]/g, ''); + + setForm({ + name: cloneName, + category: template.category, + language: template.language || 'en_US', + header_format: (template.header_type ?? 'none') as HeaderFormat, + header_content: template.header_content ?? '', + header_media_url: template.header_media_url ?? '', + header_sample: template.sample_values?.header?.[0] ?? '', + body_text: template.body_text, + body_samples: template.sample_values?.body ?? [], + footer_text: template.footer_text ?? '', + buttons: template.buttons ?? [], + }); + setDialogOpen(true); + } + function openCreate() { setEditingId(null); + setViewingOnly(false); setForm(emptyForm); setDialogOpen(true); } @@ -280,7 +327,7 @@ export function TemplateManager() { } // Refresh first, then close — re-opening the dialog // immediately should not show a stale list. - if (user) await fetchTemplates(user.id); + if (accountId) await fetchTemplates(accountId); toast.success( data.dry_run ? isEdit @@ -334,7 +381,7 @@ export function TemplateManager() { { duration: 10000 }, ); } - await fetchTemplates(user.id); + if (accountId) await fetchTemplates(accountId); } catch (err) { console.error('Template sync error:', err); toast.error(err instanceof Error ? err.message : t('toastSyncError')); @@ -491,13 +538,13 @@ export function TemplateManager() { - @@ -570,56 +617,82 @@ export function TemplateManager() { )} -
- {statusKey === 'APPROVED' && ( - - )} - {(statusKey === 'REJECTED' || statusKey === 'PAUSED') && ( - - )} +
+ {canManageTemplates && ( + <> + + {statusKey === 'APPROVED' && ( + + )} + {(statusKey === 'REJECTED' || statusKey === 'PAUSED') && ( + + )} + + + )}
@@ -634,6 +707,7 @@ export function TemplateManager() { setDialogOpen(open); if (!open) { setEditingId(null); + setViewingOnly(false); setForm(emptyForm); } }} @@ -641,12 +715,18 @@ export function TemplateManager() { - {editingId ? t('dialogEditTitle') : t('dialogNewTitle')} + {viewingOnly + ? t('dialogViewTitle') + : editingId + ? t('dialogEditTitle') + : t('dialogNewTitle')} - {editingId - ? t('dialogEditDesc') - : t('dialogNewDesc')} + {viewingOnly + ? t('dialogViewDesc') + : editingId + ? t('dialogEditDesc') + : t('dialogNewDesc')} @@ -664,7 +744,7 @@ export function TemplateManager() { placeholder={t('namePlaceholder')} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} - disabled={editingId !== null} + disabled={editingId !== null || viewingOnly} className="bg-muted border-border text-foreground placeholder:text-muted-foreground disabled:opacity-60 disabled:cursor-not-allowed" />

@@ -679,6 +759,7 @@ export function TemplateManager() { // Preserve header_content, header_media_url, and // header_sample across format switches. The submit @@ -747,7 +829,7 @@ export function TemplateManager() { }) } > - + @@ -781,8 +863,9 @@ export function TemplateManager() { onChange={(e) => setForm({ ...form, header_content: e.target.value }) } + disabled={viewingOnly} maxLength={TEMPLATE_LIMITS.headerTextMaxLength} - className="bg-muted border-border text-foreground placeholder:text-muted-foreground" + className="bg-muted border-border text-foreground placeholder:text-muted-foreground disabled:opacity-60 disabled:cursor-not-allowed" /> {headerVarCount > 0 && ( setForm({ ...form, header_sample: e.target.value }) } - className="bg-muted border-border text-foreground placeholder:text-muted-foreground" + disabled={viewingOnly} + className="bg-muted border-border text-foreground placeholder:text-muted-foreground disabled:opacity-60 disabled:cursor-not-allowed" /> )}

@@ -801,7 +885,7 @@ export function TemplateManager() { {headerNeedsMedia && (
- {form.header_format === 'image' && ( + {form.header_format === 'image' && !viewingOnly && (
headerFileRef.current?.click()} > {uploadingHeader ? ( @@ -839,7 +923,8 @@ export function TemplateManager() { onChange={(e) => setForm({ ...form, header_media_url: e.target.value }) } - className="bg-muted border-border text-foreground placeholder:text-muted-foreground" + disabled={viewingOnly} + className="bg-muted border-border text-foreground placeholder:text-muted-foreground disabled:opacity-60 disabled:cursor-not-allowed" /> {form.header_format === 'image' && form.header_media_url && ( // eslint-disable-next-line @next/next/no-img-element @@ -870,14 +955,15 @@ export function TemplateManager() { onChange={(e) => setForm({ ...form, body_text: e.target.value }) } + disabled={viewingOnly} rows={4} maxLength={TEMPLATE_LIMITS.bodyMaxLength} - className="bg-muted border-border text-foreground placeholder:text-muted-foreground resize-none" + className="bg-muted border-border text-foreground placeholder:text-muted-foreground resize-none disabled:opacity-60 disabled:cursor-not-allowed" />

{t('bodyHint')}

- + {bodyVarCount > 0 && (
- + {!viewingOnly && ( + + )}
{form.buttons.length === 0 ? (

@@ -947,6 +1037,7 @@ export function TemplateManager() {

updateButton(i, { text: e.target.value }) } - className="flex-1 bg-muted border-border text-foreground placeholder:text-muted-foreground h-8 text-xs" + className="flex-1 bg-muted border-border text-foreground placeholder:text-muted-foreground h-8 text-xs disabled:opacity-60 disabled:cursor-not-allowed" /> - + {!viewingOnly && ( + + )}
{btn.type === 'URL' && (
updateButton(i, { url: e.target.value }) } - className="bg-muted border-border text-foreground placeholder:text-muted-foreground h-8 text-xs" + className="bg-muted border-border text-foreground placeholder:text-muted-foreground h-8 text-xs disabled:opacity-60 disabled:cursor-not-allowed" /> {extractVariableIndices(btn.url).length > 0 && ( updateButton(i, { example: e.target.value }) } - className="bg-muted border-border text-foreground placeholder:text-muted-foreground h-8 text-xs" + className="bg-muted border-border text-foreground placeholder:text-muted-foreground h-8 text-xs disabled:opacity-60 disabled:cursor-not-allowed" /> )}
@@ -1030,20 +1127,22 @@ export function TemplateManager() { updateButton(i, { phone_number: e.target.value }) } - className="bg-muted border-border text-foreground placeholder:text-muted-foreground h-8 text-xs" + className="bg-muted border-border text-foreground placeholder:text-muted-foreground h-8 text-xs disabled:opacity-60 disabled:cursor-not-allowed" /> )} {btn.type === 'COPY_CODE' && ( updateButton(i, { example: e.target.value }) } - className="bg-muted border-border text-foreground placeholder:text-muted-foreground h-8 text-xs" + className="bg-muted border-border text-foreground placeholder:text-muted-foreground h-8 text-xs disabled:opacity-60 disabled:cursor-not-allowed" /> )}
@@ -1054,29 +1153,40 @@ export function TemplateManager() {
- - + {viewingOnly ? ( + + ) : ( + <> + + + + )} diff --git a/src/components/settings/whatsapp-config.tsx b/src/components/settings/whatsapp-config.tsx index 85b7a3b819..a9b4a1fca5 100644 --- a/src/components/settings/whatsapp-config.tsx +++ b/src/components/settings/whatsapp-config.tsx @@ -39,12 +39,7 @@ type ResetReason = 'token_corrupted' | 'meta_api_error' | null; export function WhatsAppConfig() { const t = useTranslations('Settings.whatsapp'); const supabase = createClient(); - // After multi-user, whatsapp_config is one-row-per-account, not - // one-row-per-user. We pull `accountId` straight off the auth - // context and key every read off it — so a teammate who just - // joined an account sees the inviter's saved config without - // having to re-enter anything. - const { user, accountId, loading: authLoading, profileLoading } = useAuth(); + const { user, accountId, canEditSettings, loading: authLoading, profileLoading } = useAuth(); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -183,6 +178,7 @@ export function WhatsAppConfig() { }, [authLoading, profileLoading, user?.id, accountId, fetchConfig]); async function handleSave() { + if (!canEditSettings) return; if (!phoneNumberId.trim()) { toast.error('Phone Number ID is required'); return; @@ -278,6 +274,7 @@ export function WhatsAppConfig() { } async function handleTestConnection() { + if (!canEditSettings) return; try { setTesting(true); const res = await fetch('/api/whatsapp/config', { method: 'GET' }); @@ -308,6 +305,7 @@ export function WhatsAppConfig() { } async function handleVerifyRegistration() { + if (!canEditSettings) return; setVerifyingRegistration(true); setRegistrationProbe(null); try { @@ -334,6 +332,7 @@ export function WhatsAppConfig() { } async function handleReset() { + if (!canEditSettings) return; if (!confirm('This will delete the current WhatsApp config so you can re-enter it. Continue?')) { return; } @@ -396,16 +395,26 @@ export function WhatsAppConfig() {
{/* Main config form */}
+ {!canEditSettings && ( + + + Read-only access + + You must be an Owner or Admin to configure the WhatsApp connection. + + + )} + {/* Corrupted-token reset banner */} {showResetBanner && ( - +
- +
- + Stored token can't be decrypted - + {statusMessage} @@ -620,6 +633,7 @@ export function WhatsAppConfig() {
setVerifyToken(e.target.value)} @@ -636,6 +650,7 @@ export function WhatsAppConfig() { {t('optional')}