Files
agenciapsilmno/src/views/pages/saas/SaasTwilioConfigPage.vue
T
Leonardo 7c20b518d4 Sessoes 1-6 acumuladas: hardening B2, defesa em camadas, +192 testes
Repositorio estava ha ~5 sessoes sem commit. Consolida tudo desde d088a89.

Ver commit.md na raiz para descricao completa por sessao.

# Numeros
- A# auditoria abertos: 0/30
- V# verificacoes abertos: 5/52 (todos adiados com plano)
- T# testes escritos: 10/10
- Vitest: 192/192
- SQL integration: 33/33
- E2E (Playwright, novo): 5/5
- Migrations: 17 (10 novas Sessao 6)
- Areas auditadas: 7 (+documentos com 10 V#)

# Highlights Sessao 6 (hoje)
- V#34/V#41 Opcao B2: tenant_features com plano + override (RPC SECURITY DEFINER, tela /saas/tenant-features)
- A#20 rev2 self-hosted: defesa em 5 camadas (honeypot + rate limit + math captcha condicional + paranoid mode + dashboard /saas/security)
- Documentos hardening (V#43-V#49): tenant scoping em storage policies (vazamento entre clinicas eliminado), RPC validate_share_token, signatures policy granular
- SaaS Twilio Config (/saas/twilio-config): UI editavel para SID/webhook/cotacao; AUTH_TOKEN permanece em env var
- T#9 + T#10: useAgendaEvents.spec.js + Playwright E2E (descobriu bug no front que foi corrigido)

# Sessoes anteriores (1-5) consolidadas
- Sessao 1: auth/router/session, normalizeRole extraido
- Sessao 2: agenda - composables/services consolidados
- Sessao 3: pacientes - tenant_id em todas queries
- Sessao 4: security review pagina publica - 14/15 vulnerabilidades corrigidas
- Sessao 5: SaaS - P0 (A#30: 7 tabelas com RLS off corrigidas)

# .gitignore ajustado
- supabase/* + !supabase/functions/ (mantem 10 edge functions, ignora .temp/migrations gerados pelo CLI)
- database-novo/backups/ (regeneravel via db.cjs backup)
- test-results/ + playwright-report/
- .claude/settings.local.json (config local com senha de dev removida do tracking)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:42:46 -03:00

347 lines
17 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
|--------------------------------------------------------------------------
| Agência PSI SaaS / Twilio Config
|--------------------------------------------------------------------------
| Painel para editar config operacional Twilio (account_sid, webhook URL,
| cotação, margem) sem precisar de redeploy.
|
| AUTH_TOKEN continua em env var por segurança painel mostra status.
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted } from 'vue';
import { supabase } from '@/lib/supabase/client';
import InputText from 'primevue/inputtext';
import InputNumber from 'primevue/inputnumber';
import Textarea from 'primevue/textarea';
import Button from 'primevue/button';
import Tag from 'primevue/tag';
import Accordion from 'primevue/accordion';
import AccordionPanel from 'primevue/accordionpanel';
import AccordionHeader from 'primevue/accordionheader';
import AccordionContent from 'primevue/accordioncontent';
import { useToast } from 'primevue/usetoast';
const toast = useToast();
const loading = ref(false);
const saving = ref(false);
const helpOpen = ref(true);
const form = ref({
account_sid: '',
whatsapp_webhook_url: '',
usd_brl_rate: 5.5,
margin_multiplier: 1.4,
notes: ''
});
const meta = ref({ updated_at: null, updated_by: null });
// Status do AUTH_TOKEN: tentamos uma chamada que NÃO mexe em nada (sync com canal inválido)
// e checamos se a edge devolve "TWILIO_AUTH_TOKEN não configurado". Se sim, env tá vazia.
const tokenStatus = ref('unknown'); // 'configured' | 'missing' | 'unknown'
const tokenChecking = ref(false);
const sidValid = computed(() => !form.value.account_sid || /^AC[a-zA-Z0-9]{32}$/.test(form.value.account_sid));
const urlValid = computed(() => !form.value.whatsapp_webhook_url || /^https?:\/\//.test(form.value.whatsapp_webhook_url));
async function loadConfig() {
loading.value = true;
try {
const { data, error } = await supabase.rpc('get_twilio_config');
if (error) throw error;
if (data) {
form.value = {
account_sid: data.account_sid || '',
whatsapp_webhook_url: data.whatsapp_webhook_url || '',
usd_brl_rate: Number(data.usd_brl_rate) || 5.5,
margin_multiplier: Number(data.margin_multiplier) || 1.4,
notes: data.notes || ''
};
meta.value = { updated_at: data.updated_at, updated_by: data.updated_by };
}
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'falha config', life: 4000 });
} finally {
loading.value = false;
}
}
async function saveConfig() {
if (!sidValid.value) {
toast.add({ severity: 'warn', summary: 'SID inválido', detail: 'O Account SID deve começar com AC seguido de 32 caracteres.', life: 4000 });
return;
}
if (!urlValid.value) {
toast.add({ severity: 'warn', summary: 'URL inválida', detail: 'O webhook deve começar com http:// ou https://', life: 4000 });
return;
}
saving.value = true;
try {
const { data, error } = await supabase.rpc('update_twilio_config', {
p_account_sid: form.value.account_sid || null,
p_whatsapp_webhook_url: form.value.whatsapp_webhook_url || null,
p_usd_brl_rate: form.value.usd_brl_rate,
p_margin_multiplier: form.value.margin_multiplier,
p_notes: form.value.notes || null
});
if (error) throw error;
if (data) meta.value = { updated_at: data.updated_at, updated_by: data.updated_by };
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Configuração atualizada. A edge function lê do banco a cada chamada.', life: 3000 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'falha ao salvar', life: 4500 });
} finally {
saving.value = false;
}
}
async function checkTokenStatus() {
tokenChecking.value = true;
try {
// Chamada inofensiva: action inválida → edge function ainda checa env vars antes
// Se devolver "TWILIO_AUTH_TOKEN não configurado" → env vazia
// Se devolver outro erro (ex: "action obrigatória" ou "Não autorizado") → env tem valor
const { data, error } = await supabase.functions.invoke('twilio-whatsapp-provision', {
body: { action: 'sync_usage' }
});
let msg = '';
if (error) {
try {
const ctxBody = await error.context?.json?.();
msg = ctxBody?.error || error.message || '';
} catch {
msg = error.message || '';
}
} else if (data?.error) {
msg = data.error;
}
if (/AUTH_TOKEN.*não configurado/i.test(msg)) {
tokenStatus.value = 'missing';
} else {
// qualquer outro erro (incl. ACCOUNT_SID, "action", etc) → token está setado
tokenStatus.value = 'configured';
}
} catch {
tokenStatus.value = 'unknown';
} finally {
tokenChecking.value = false;
}
}
const tokenSeverity = computed(() => ({
configured: 'success',
missing: 'danger',
unknown: 'secondary'
})[tokenStatus.value]);
const tokenLabel = computed(() => ({
configured: 'configurado ✓',
missing: 'não configurado',
unknown: 'desconhecido (clique em "Verificar")'
})[tokenStatus.value]);
onMounted(async () => {
await loadConfig();
});
</script>
<template>
<div class="px-3 md:px-4 py-4 flex flex-col gap-4">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="text-[1.1rem] font-bold tracking-tight">Configuração Twilio</div>
<div class="text-[0.95rem] text-[var(--text-color-secondary)] mt-1">
Edite a config operacional sem redeploy. O <b>Auth Token</b> (secret) continua em variável de ambiente da Edge Function por segurança.
</div>
<div v-if="meta.updated_at" class="text-xs text-[var(--text-color-secondary)] mt-2">
Última atualização: {{ new Date(meta.updated_at).toLocaleString('pt-BR') }}
</div>
</div>
<!-- Card explicativo -->
<div class="rounded-md border border-indigo-400/30 bg-indigo-400/5">
<Accordion :value="helpOpen ? '0' : null" @update:value="(v) => helpOpen = (v === '0')">
<AccordionPanel value="0">
<AccordionHeader>
<div class="flex items-center gap-2">
<i class="pi pi-info-circle text-indigo-400" />
<span class="font-semibold">Como funciona guia rápido</span>
</div>
</AccordionHeader>
<AccordionContent>
<div class="flex flex-col gap-4 text-[0.92rem] leading-relaxed">
<section>
<div class="font-semibold mb-2">Onde cada coisa fica</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div class="rounded-md border border-emerald-400/30 bg-emerald-400/5 p-3">
<div class="font-medium text-emerald-300">No banco (editável aqui) </div>
<ul class="list-disc pl-5 mt-1 text-xs text-[var(--text-color-secondary)] space-y-0.5">
<li><b>Account SID</b> identificador público da conta</li>
<li><b>Webhook URL</b> endpoint que recebe callbacks do Twilio</li>
<li><b>Cotação USD/BRL</b> usada nos cálculos de custo</li>
<li><b>Multiplicador de margem</b> markup aplicado ao preço por mensagem</li>
</ul>
</div>
<div class="rounded-md border border-amber-400/40 bg-amber-400/5 p-3">
<div class="font-medium text-amber-300">Em env var (CLI obrigatório) 🔒</div>
<ul class="list-disc pl-5 mt-1 text-xs text-[var(--text-color-secondary)] space-y-0.5">
<li><b>TWILIO_AUTH_TOKEN</b> secret que envia mensagens</li>
</ul>
<div class="text-xs text-[var(--text-color-secondary)] mt-2">
Mantido isolado pra reduzir superfície de ataque. Se vazar, atacante manda WhatsApp/SMS na sua conta até estourar limite.
</div>
</div>
</div>
</section>
<section>
<div class="font-semibold mb-2">Como configurar do zero</div>
<ol class="list-decimal pl-6 space-y-2 text-[var(--text-color-secondary)]">
<li>Crie a conta master em <a href="https://www.twilio.com/console" target="_blank" class="underline text-indigo-300">twilio.com/console</a> e copie o <b>Account SID</b> + <b>Auth Token</b>.</li>
<li>
Configure o secret na Edge Function:
<pre class="mt-1 p-2 rounded bg-black/30 font-mono text-xs overflow-x-auto">npx supabase secrets set TWILIO_AUTH_TOKEN=seu_token_aqui</pre>
</li>
<li>
Cole o Account SID no campo abaixo (formato <code class="px-1 rounded bg-black/30 text-[11px]">AC + 32 chars</code>).
</li>
<li>
Configure o webhook URL apontando pra sua função de webhook:
<pre class="mt-1 p-2 rounded bg-black/30 font-mono text-xs overflow-x-auto">https://&lt;projeto&gt;.supabase.co/functions/v1/twilio-whatsapp-webhook</pre>
</li>
<li>Ajuste cotação USD/BRL e multiplicador de margem conforme sua estratégia comercial.</li>
<li>Salve. As Edge Functions vão ler a config nova a cada chamada (sem redeploy).</li>
</ol>
</section>
<section>
<div class="font-semibold mb-2">Por que o Auth Token não fica aqui</div>
<p class="text-[var(--text-color-secondary)]">
Decisão de segurança: dados editáveis no painel ficam no banco com RLS estrita (saas_admin). Mas o Auth Token é o único que poder de <b>gerar custos reais</b> (envio de SMS/WhatsApp). Mantê-lo isolado em env var significa que mesmo um vazamento do banco (backup público acidental, RLS bug, dump em dev) não compromete o token.
</p>
<p class="text-[var(--text-color-secondary)] mt-2">
O painel mostra apenas <b>se o token está configurado</b> (/) sem nunca ler ou expor o valor.
</p>
</section>
<section>
<div class="font-semibold mb-2">Como testar depois de salvar</div>
<ol class="list-decimal pl-6 space-y-1 text-[var(--text-color-secondary)]">
<li> em <span class="font-mono text-xs">/saas/twilio-whatsapp</span></li>
<li>Clique em "Sincronizar uso" deve devolver "0 canais atualizados" se ainda não subcontas, sem erro.</li>
<li>Provisione uma subconta de teste pra um tenant.</li>
<li>Use o botão de envio de teste pra validar o pipeline completo.</li>
</ol>
</section>
</div>
</AccordionContent>
</AccordionPanel>
</Accordion>
</div>
<!-- Status do AUTH_TOKEN -->
<div class="rounded-md border border-amber-400/40 bg-amber-400/5 p-5">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div>
<div class="font-semibold flex items-center gap-2">
<i class="pi pi-key text-amber-400" />
TWILIO_AUTH_TOKEN (env var)
</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">
Secret que autentica todas as chamadas pra API do Twilio. Não armazenado no banco.
</div>
</div>
<div class="flex items-center gap-3">
<Tag :value="tokenLabel" :severity="tokenSeverity" />
<Button label="Verificar" icon="pi pi-search" size="small" outlined :loading="tokenChecking" @click="checkTokenStatus" />
</div>
</div>
<div class="mt-3 text-xs text-[var(--text-color-secondary)]">
<b>Pra trocar:</b>
<pre class="mt-1 p-2 rounded bg-black/30 font-mono text-xs overflow-x-auto">npx supabase secrets set TWILIO_AUTH_TOKEN=novo_token</pre>
Depois redeploy as Edge Functions afetadas (ou aguarde restart automático).
</div>
</div>
<!-- Form -->
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="font-semibold mb-4">Configuração editável</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="md:col-span-2">
<label class="text-xs text-[var(--text-color-secondary)]">Account SID (formato AC + 32 chars)</label>
<InputText
v-model="form.account_sid"
placeholder="AC..."
class="w-full mt-1"
:invalid="!sidValid"
:disabled="loading || saving"
/>
<div v-if="!sidValid" class="text-xs text-rose-400 mt-1">SID precisa começar com AC seguido de 32 caracteres alfanuméricos.</div>
</div>
<div class="md:col-span-2">
<label class="text-xs text-[var(--text-color-secondary)]">Webhook URL (callbacks de status do Twilio)</label>
<InputText
v-model="form.whatsapp_webhook_url"
placeholder="https://..."
class="w-full mt-1"
:invalid="!urlValid"
:disabled="loading || saving"
/>
<div v-if="!urlValid" class="text-xs text-rose-400 mt-1">URL deve começar com http:// ou https://</div>
</div>
<div>
<label class="text-xs text-[var(--text-color-secondary)]">Cotação USD/BRL (1 USD vale)</label>
<InputNumber
v-model="form.usd_brl_rate"
:min="0.01"
:max="99.99"
:minFractionDigits="2"
:maxFractionDigits="4"
mode="decimal"
class="w-full mt-1"
:disabled="loading || saving"
/>
</div>
<div>
<label class="text-xs text-[var(--text-color-secondary)]">Multiplicador de margem (1.0 = sem margem)</label>
<InputNumber
v-model="form.margin_multiplier"
:min="1"
:max="10"
:minFractionDigits="2"
:maxFractionDigits="4"
mode="decimal"
class="w-full mt-1"
:disabled="loading || saving"
/>
</div>
<div class="md:col-span-2">
<label class="text-xs text-[var(--text-color-secondary)]">Notas internas (não aparece pro tenant)</label>
<Textarea
v-model="form.notes"
rows="3"
class="w-full mt-1"
autoResize
:disabled="loading || saving"
/>
</div>
</div>
<div class="mt-4 flex items-center justify-end gap-2">
<Button label="Recarregar" icon="pi pi-refresh" outlined :disabled="saving" :loading="loading" @click="loadConfig" />
<Button label="Salvar" icon="pi pi-check" :loading="saving" :disabled="!sidValid || !urlValid" @click="saveConfig" />
</div>
</div>
</div>
</template>