7c20b518d4
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>
347 lines
17 KiB
Vue
347 lines
17 KiB
Vue
<!--
|
||
|--------------------------------------------------------------------------
|
||
| 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 só 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://<projeto>.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 dá 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>Vá 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 há 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>
|