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>
This commit is contained in:
Leonardo
2026-04-19 15:42:46 -03:00
parent d088a89fb7
commit 7c20b518d4
175 changed files with 37325 additions and 37968 deletions
+205 -174
View File
@@ -23,6 +23,7 @@ import Textarea from 'primevue/textarea';
import Checkbox from 'primevue/checkbox';
import Message from 'primevue/message';
import Popover from 'primevue/popover';
import Dialog from 'primevue/dialog';
import Accordion from 'primevue/accordion';
import AccordionPanel from 'primevue/accordionpanel';
import AccordionHeader from 'primevue/accordionheader';
@@ -32,15 +33,32 @@ import Select from 'primevue/select';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import { digitsOnly, isValidEmail, toISODate, generateCPF, fmtCPF } from '@/utils/validators';
import MathCaptchaChallenge from '@/components/security/MathCaptchaChallenge.vue';
const route = useRoute();
const toast = useToast();
const isDev = import.meta.env.DEV;
// A#20 rev2 — defesa em camadas self-hosted
// Honeypot: campo invisível que humano nunca preenche
const honeypot = ref('');
// Math captcha: ativado sob demanda quando edge function devolve 403 captcha-required
const captchaRequired = ref(false);
const captchaId = ref('');
const captchaAnswer = ref(null);
const salvando = ref(false);
const sucesso = ref(false);
// A#25: dialog de política de privacidade (LGPD)
const policyOpen = ref(false);
const token = computed(() => String(route.query.t || '').trim());
const tokenOk = computed(() => token.value.length >= 10);
// A#21: validação mais estrita — aceita UUID (com ou sem hífen, 32 hex chars).
// Ainda é cosmético (servidor valida de verdade), mas reduz falsos "verificado".
const TOKEN_RX = /^[0-9a-f]{32}$|^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const tokenOk = computed(() => TOKEN_RX.test(token.value));
// ------------------------------------------------------
// Helpers (✅ ficam ANTES do enviar())
@@ -134,7 +152,6 @@ function preencherMock() {
form.rg = generateRG();
form.observacoes = maybe(0.5) ? 'Cadastro realizado via link externo.' : 'Tenho disponibilidade no período da noite.';
form.notas_internas = maybe(0.5) ? 'Paciente demonstrou interesse em iniciar nas próximas semanas.' : '';
form.onde_nos_conheceu = pick(['Instagram', 'Google', 'Indicação', 'Site', 'Threads', 'Outro']);
form.encaminhado_por = maybe(0.45) ? `${pick(first)} ${pick(last)}` : '';
@@ -167,63 +184,9 @@ function preencherMock() {
toast.add({ severity: 'info', summary: 'Exemplo', detail: 'Campos preenchidos com dados simulados.', life: 1800 });
}
// ------------------------------------------------------
// Foto (upload opcional)
// ------------------------------------------------------
const fotoFile = ref(null);
const fotoPreviewUrl = ref('');
const fotoErro = ref('');
function limparFoto() {
fotoFile.value = null;
fotoErro.value = '';
if (fotoPreviewUrl.value) URL.revokeObjectURL(fotoPreviewUrl.value);
fotoPreviewUrl.value = '';
form.foto_url = '';
}
function onFotoPicked(evt) {
fotoErro.value = '';
const file = evt?.target?.files?.[0];
if (!file) return;
if (!file.type?.startsWith('image/')) {
fotoErro.value = 'Arquivo inválido. Envie uma imagem.';
return;
}
const maxMb = 8;
if (file.size > maxMb * 1024 * 1024) {
fotoErro.value = `Imagem muito grande. Máx: ${maxMb}MB.`;
return;
}
fotoFile.value = file;
if (fotoPreviewUrl.value) URL.revokeObjectURL(fotoPreviewUrl.value);
fotoPreviewUrl.value = URL.createObjectURL(file);
}
const AVATAR_BUCKET = 'avatars';
async function uploadFotoSeHouver() {
if (!fotoFile.value) return null;
const ext = (fotoFile.value.name.split('.').pop() || 'jpg').toLowerCase();
const filePath = `intakes/${token.value}/${Date.now()}.${ext}`;
try {
const { error: upErr } = await supabase.storage.from(AVATAR_BUCKET).upload(filePath, fotoFile.value, {
upsert: true,
contentType: fotoFile.value.type
});
if (upErr) throw upErr;
const { data: pub } = supabase.storage.from(AVATAR_BUCKET).getPublicUrl(filePath);
return pub?.publicUrl || null;
} catch (e) {
toast.add({ severity: 'warn', summary: 'Foto', detail: `Upload falhou: ${e?.message || 'erro desconhecido'}`, life: 4500 });
return null;
}
}
// Upload de foto removido da página pública (A#15):
// O bucket não aceita mais upload anônimo. O terapeuta pode pedir a foto
// depois, por outro canal, se for necessário.
// ------------------------------------------------------
// CEP (ViaCEP)
@@ -299,8 +262,7 @@ const navItems = [
{ value: '0', label: 'Informações pessoais', icon: 'pi pi-user' },
{ value: '1', label: 'Endereço', icon: 'pi pi-map-marker' },
{ value: '2', label: 'Dados adicionais', icon: 'pi pi-tags' },
{ value: '3', label: 'Responsável', icon: 'pi pi-users' },
{ value: '4', label: 'Anotações', icon: 'pi pi-file-edit' }
{ value: '3', label: 'Responsável', icon: 'pi pi-users' }
];
const navPopover = ref(null);
@@ -333,7 +295,6 @@ onBeforeUnmount(() => {
if (mql.removeEventListener) mql.removeEventListener('change', mqlHandler);
else mql.removeListener(mqlHandler);
}
if (fotoPreviewUrl.value) URL.revokeObjectURL(fotoPreviewUrl.value);
});
// ------------------------------------------------------
@@ -378,7 +339,6 @@ function resetForm() {
onde_nos_conheceu: '',
encaminhado_por: '',
observacoes: '',
notas_internas: '',
nacionalidade: '',
email_alternativo: '',
@@ -396,9 +356,7 @@ function resetForm() {
telefone_responsavel: '',
cpf_responsavel: '',
observacao_responsavel: '',
cobranca_no_responsavel: false,
foto_url: ''
cobranca_no_responsavel: false
};
}
@@ -463,53 +421,53 @@ async function enviar() {
salvando.value = true;
try {
const fotoUrl = await uploadFotoSeHouver();
if (fotoUrl) form.foto_url = fotoUrl;
// A#17: notas_internas NÃO é enviado (campo interno do terapeuta, não
// deve vir do paciente). A#15: avatar_url removido (upload não é mais
// possível anonimamente). Todos os campos texto são recortados em
// length máximo client-side (defesa em camadas — o RPC também valida).
const truncate = (v, n) => {
const s = cleanStr(v);
return s ? String(s).slice(0, n) : null;
};
const payload = {
// essenciais
nome_completo: cleanStr(form.nome_completo),
email_principal: cleanStr(form.email_principal)?.toLowerCase() || null,
nome_completo: truncate(form.nome_completo, 200),
email_principal: cleanStr(form.email_principal)?.toLowerCase().slice(0, 120) || null,
telefone: digitsOnly(form.telefone),
avatar_url: form.foto_url || null,
// alternativos
email_alternativo: cleanStr(form.email_alternativo)?.toLowerCase() || null,
email_alternativo: cleanStr(form.email_alternativo)?.toLowerCase().slice(0, 120) || null,
telefone_alternativo: digitsOnly(form.telefone_alternativo) || null,
// docs / endereço
cpf: digitsOnly(form.cpf),
rg: cleanStr(form.rg),
rg: truncate(form.rg, 20),
cep: digitsOnly(form.cep),
pais: cleanStr(form.pais) || 'Brasil',
cidade: cleanStr(form.cidade),
estado: cleanStr(form.estado) || 'SP',
endereco: cleanStr(form.endereco),
numero: cleanStr(form.numero),
bairro: cleanStr(form.bairro),
complemento: cleanStr(form.complemento),
pais: truncate(form.pais, 60) || 'Brasil',
cidade: truncate(form.cidade, 120),
estado: truncate(form.estado, 2) || 'SP',
endereco: truncate(form.endereco, 200),
numero: truncate(form.numero, 20),
bairro: truncate(form.bairro, 120),
complemento: truncate(form.complemento, 120),
// pessoais
data_nascimento: isoBirth || null,
naturalidade: cleanStr(form.naturalidade),
naturalidade: truncate(form.naturalidade, 120),
genero: cleanStr(form.genero),
estado_civil: cleanStr(form.estado_civil),
// adicionais (existem na tabela!)
profissao: cleanStr(form.profissao),
escolaridade: cleanStr(form.escolaridade),
nacionalidade: cleanStr(form.nacionalidade),
// adicionais
profissao: truncate(form.profissao, 120),
escolaridade: truncate(form.escolaridade, 120),
nacionalidade: truncate(form.nacionalidade, 80),
// origem + texto
onde_nos_conheceu: cleanStr(form.onde_nos_conheceu),
encaminhado_por: cleanStr(form.encaminhado_por),
observacoes: cleanStr(form.observacoes),
// ⚠️ eu recomendo NÃO enviar isso no externo,
// mas a coluna existe — então deixo opcional:
notas_internas: cleanStr(form.notas_internas),
// origem + observações
onde_nos_conheceu: truncate(form.onde_nos_conheceu, 80),
encaminhado_por: truncate(form.encaminhado_por, 120),
observacoes: truncate(form.observacoes, 2000),
// consent
consent: !!consent.value
@@ -519,28 +477,68 @@ async function enviar() {
if (payload[k] === undefined) delete payload[k];
});
let { error } = await supabase.rpc('create_patient_intake_request_v2', {
p_token: token.value,
p_payload: payload
});
// A#24: envia user_agent pra log de tentativas (sem PII).
const clientInfo = (typeof navigator !== 'undefined' && navigator.userAgent)
? String(navigator.userAgent).slice(0, 500)
: null;
const isFnMissing = error && (error.code === '42883' || /create_patient_intake_request_v2/i.test(error.message));
const isOldSchemaColumn = error && (/column .* of relation .*patient_intake_requests.* does not exist/i.test(error.message) || /column "name" of relation "patient_intake_requests" does not exist/i.test(error.message));
if (isFnMissing || isOldSchemaColumn) {
const { error: e2 } = await supabase.rpc('create_patient_intake_request', {
p_token: token.value,
p_name: payload.nome_completo,
p_email: payload.email_principal,
p_phone: payload.telefone,
p_notes: payload.observacoes || null,
p_consent: !!payload.consent
});
error = e2;
// A#20 rev2: se já está exigindo captcha mas usuário não respondeu, bloqueia local
if (captchaRequired.value && (!captchaId.value || captchaAnswer.value == null)) {
toast.add({ severity: 'warn', summary: 'Verificação', detail: 'Responda a pergunta de verificação antes de enviar.', life: 3000 });
salvando.value = false;
return;
}
if (error) throw error;
const { data, error } = await supabase.functions.invoke('submit-patient-intake', {
body: {
token: token.value,
payload,
website: honeypot.value, // honeypot field
captcha_id: captchaId.value || null,
captcha_answer: captchaAnswer.value,
client_info: clientInfo
}
});
// Quando edge devolve 4xx/5xx, supabase-js coloca a Response em error.context.
// O body com {error: 'captcha-required' | 'rate-limited' | ...} continua acessível.
let errBody = null;
if (error?.context?.json) {
try { errBody = await error.context.json(); } catch { /* sem body */ }
}
const errMsg = String(errBody?.error || data?.error || error?.message || '');
if (/captcha-required/i.test(errMsg)) {
captchaRequired.value = true;
captchaId.value = '';
captchaAnswer.value = null;
toast.add({ severity: 'warn', summary: 'Verificação extra', detail: 'Por segurança, responda a pergunta abaixo.', life: 3500 });
salvando.value = false;
return;
}
if (/captcha-wrong/i.test(errMsg)) {
captchaId.value = '';
captchaAnswer.value = null;
toast.add({ severity: 'warn', summary: 'Verificação', detail: 'Resposta incorreta. Tente novamente.', life: 3500 });
salvando.value = false;
return;
}
if (/rate-limited/i.test(errMsg)) {
const retryAfter = errBody?.retry_after_seconds || data?.retry_after_seconds;
const after = retryAfter ? Math.ceil(retryAfter / 60) : null;
toast.add({
severity: 'warn',
summary: 'Muitas tentativas',
detail: after ? `Tente novamente em ${after} min.` : 'Tente novamente mais tarde.',
life: 6000
});
salvando.value = false;
return;
}
if (error) throw new Error(data?.message || error.message || 'Falha ao enviar');
if (data?.error) throw new Error(data?.message || data.error);
sucesso.value = true;
toast.add({ severity: 'success', summary: 'Enviado', detail: 'Cadastro enviado com sucesso.', life: 2500 });
@@ -620,7 +618,8 @@ watch(
<!-- actions -->
<div class="flex shrink-0 flex-wrap items-center gap-2">
<Button label="Preencher exemplo" icon="pi pi-bolt" severity="secondary" outlined class="!border-white/20 !text-slate-100" :disabled="salvando || sucesso || !tokenOk" @click="preencherMock" />
<!-- A#26: Botão de preencher mock apenas em DEV -->
<Button v-if="isDev" label="Preencher exemplo" icon="pi pi-bolt" severity="secondary" outlined class="!border-white/20 !text-slate-100" :disabled="salvando || sucesso || !tokenOk" @click="preencherMock" />
<Button label="Enviar" icon="pi pi-send" class="!bg-emerald-400/90 !border-emerald-400/50 !text-slate-950" :loading="salvando" :disabled="salvando || sucesso || !tokenOk" @click="enviar" />
</div>
</div>
@@ -635,52 +634,8 @@ watch(
<!-- Left panel (inside modal) -->
<aside class="col-span-12 md:col-span-4">
<div class="rounded-2xl border border-white/10 bg-white/5 p-4">
<!-- Photo -->
<div class="flex items-start gap-4">
<div class="relative">
<div class="h-20 w-20 overflow-hidden rounded-2xl border border-white/10 bg-white/5">
<img v-if="fotoPreviewUrl || form.foto_url" :src="fotoPreviewUrl || form.foto_url" alt="foto" class="h-full w-full object-cover" />
<div v-else class="grid h-full w-full place-items-center text-slate-300">
<i class="pi pi-user text-2xl opacity-70" />
</div>
</div>
<div class="pointer-events-none absolute -inset-1 rounded-2xl bg-emerald-400/10 blur-lg" />
</div>
<div class="min-w-0">
<div class="text-sm font-semibold">Foto (opcional)</div>
<div class="mt-1 text-xs text-slate-300">Se preferir, pode deixar sem foto.</div>
<div class="mt-3 flex flex-col gap-2">
<label class="cursor-pointer rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-xs text-slate-200 hover:bg-white/10">
<input type="file" accept="image/*" class="hidden" :disabled="salvando" @change="onFotoPicked" />
<span class="inline-flex items-center gap-2">
<i class="pi pi-upload" />
Enviar imagem
</span>
</label>
<label class="cursor-pointer rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-xs text-slate-200 hover:bg-white/10">
<input type="file" accept="image/*" capture="user" class="hidden" :disabled="salvando" @change="onFotoPicked" />
<span class="inline-flex items-center gap-2">
<i class="pi pi-camera" />
Usar câmera (celular)
</span>
</label>
<div v-if="fotoFile" class="flex gap-2">
<Button type="button" icon="pi pi-trash" severity="secondary" outlined size="small" label="Remover" class="!border-white/20 !text-slate-100" :disabled="salvando" @click="limparFoto" />
</div>
<div v-if="fotoErro" class="rounded-xl border border-rose-400/30 bg-rose-400/10 px-3 py-2 text-xs text-rose-100">
{{ fotoErro }}
</div>
</div>
</div>
</div>
<!-- Section chips -->
<div class="mt-5">
<div>
<div class="text-xs font-semibold text-slate-200">Seções</div>
<div class="mt-2 flex flex-wrap gap-2">
<button
@@ -1124,19 +1079,6 @@ watch(
</AccordionContent>
</AccordionPanel>
<!-- 4 -->
<AccordionPanel value="4">
<AccordionHeader :ref="(el) => setPanelHeaderRef(el, 4)"> 5. Anotações </AccordionHeader>
<AccordionContent>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-file-edit" />
<Textarea id="f_notas_internas" v-model="form.notas_internas" class="w-full" autoResize rows="6" variant="filled" :disabled="salvando" />
</IconField>
<label for="f_notas_internas">Notas</label>
</FloatLabel>
</AccordionContent>
</AccordionPanel>
</Accordion>
<!-- Footer / Consent + CTA -->
@@ -1146,7 +1088,12 @@ watch(
<Checkbox v-model="consent" :binary="true" :disabled="salvando" inputId="ext_consent" />
<div class="min-w-0">
<label for="ext_consent" class="block text-sm font-semibold text-slate-100"> Concordo em enviar meus dados para o terapeuta. </label>
<div class="mt-1 text-xs text-slate-300">Seus dados serão usados apenas para contato e organização do atendimento.</div>
<div class="mt-1 text-xs text-slate-300">
Seus dados serão usados apenas para contato e organização do atendimento.
<button type="button" class="ml-1 underline text-emerald-300 hover:text-emerald-200 focus:outline-none" @click="policyOpen = true">
Ver política de privacidade
</button>
</div>
<div v-if="errors.consentimento" class="mt-2 rounded-xl border border-rose-400/30 bg-rose-400/10 px-3 py-2 text-xs text-rose-100">
{{ errors.consentimento }}
@@ -1155,6 +1102,25 @@ watch(
</div>
</div>
<!-- A#20 rev2: HONEYPOT invisível bot preenche tudo, humano nunca -->
<div class="absolute -left-[9999px]" aria-hidden="true">
<label for="ext_website">Não preencha este campo</label>
<input
id="ext_website"
v-model="honeypot"
type="text"
tabindex="-1"
autocomplete="off"
/>
</div>
<!-- A#20 rev2: math captcha CONDICIONAL aparece quando edge pedir -->
<MathCaptchaChallenge
v-if="captchaRequired"
v-model:id="captchaId"
v-model:answer="captchaAnswer"
/>
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div class="text-xs text-slate-300">Se algo falhar, peça um novo link ao terapeuta.</div>
@@ -1163,7 +1129,7 @@ watch(
icon="pi pi-send"
class="!bg-emerald-400/90 !border-emerald-400/50 !text-slate-950 md:!min-w-[240px]"
:loading="salvando"
:disabled="salvando || sucesso || !tokenOk"
:disabled="salvando || sucesso || !tokenOk || (captchaRequired && (!captchaId || captchaAnswer == null))"
@click="enviar"
/>
</div>
@@ -1178,5 +1144,70 @@ watch(
<div class="mt-4 text-center text-xs text-slate-400">Ao enviar, você confirma que está fornecendo informações verdadeiras e que autoriza o contato.</div>
</div>
</div>
<!-- A#25: Política de privacidade (dialog inline) -->
<Dialog
v-model:visible="policyOpen"
modal
header="Política de privacidade"
:style="{ width: '92vw', maxWidth: '720px' }"
:breakpoints="{ '640px': '96vw' }"
:draggable="false"
:dismissableMask="true"
>
<div class="text-sm leading-relaxed text-[var(--text-color)] space-y-4">
<section>
<h3 class="text-base font-semibold">Finalidade do tratamento</h3>
<p>Seus dados pessoais são coletados exclusivamente para viabilizar o contato do terapeuta com você e organizar o atendimento clínico. Não são usados para marketing, compartilhamento com terceiros ou perfilamento.</p>
</section>
<section>
<h3 class="text-base font-semibold">Dados coletados</h3>
<ul class="list-disc pl-5 space-y-1">
<li>Identificação: nome completo, CPF, RG (opcional), data de nascimento, gênero, estado civil, nacionalidade, naturalidade.</li>
<li>Contato: telefone(s), e-mail(s), endereço.</li>
<li>Contexto: profissão, escolaridade, parente/responsável, origem do contato, observações que você optar por fornecer.</li>
</ul>
</section>
<section>
<h3 class="text-base font-semibold">Base legal (LGPD)</h3>
<p>Tratamos seus dados com base no <b>consentimento</b> que você fornece ao marcar a caixa correspondente (Art. 7º, I da Lei 13.709/2018), e quando aplicável, também com base na execução de contrato de prestação de serviço terapêutico (Art. 7º, V).</p>
</section>
<section>
<h3 class="text-base font-semibold">Retenção</h3>
<p>Dados de pré-cadastro são mantidos pelo tempo necessário para a conversão em paciente (ou descarte, se não houver andamento). Após início do vínculo terapêutico, seguem as regras aplicáveis ao prontuário clínico (Resolução CFP 001/2009: no mínimo 5 anos após último atendimento).</p>
</section>
<section>
<h3 class="text-base font-semibold">Seus direitos</h3>
<p>A qualquer momento você pode solicitar, sem custo:</p>
<ul class="list-disc pl-5 space-y-1">
<li>Confirmação da existência de tratamento;</li>
<li>Acesso aos seus dados;</li>
<li>Correção de dados incompletos, inexatos ou desatualizados;</li>
<li>Anonimização, bloqueio ou eliminação de dados desnecessários;</li>
<li>Portabilidade dos dados;</li>
<li>Eliminação dos dados pessoais tratados com base no consentimento;</li>
<li>Revogação do consentimento.</li>
</ul>
<p class="mt-2">Para exercer qualquer desses direitos, entre em contato diretamente com o profissional que enviou este link.</p>
</section>
<section>
<h3 class="text-base font-semibold">Segurança</h3>
<p>Adotamos medidas técnicas (criptografia em trânsito, controle de acesso, registros de auditoria) e administrativas para proteger seus dados. Ainda assim, nenhum sistema é 100% seguro comprometa apenas o estritamente necessário.</p>
</section>
<section class="text-xs text-[var(--text-color-secondary)]">
<p>Esta política pode ser atualizada. Data da última revisão: 18/04/2026.</p>
</section>
</div>
<template #footer>
<Button label="Entendi" icon="pi pi-check" @click="policyOpen = false" />
</template>
</Dialog>
</div>
</template>
@@ -1,423 +0,0 @@
<script setup>
import { computed, ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { supabase } from '@/lib/supabase/client';
import Chip from 'primevue/chip';
import Accordion from 'primevue/accordion';
import AccordionTab from 'primevue/accordiontab';
import Avatar from 'primevue/avatar';
import AvatarGroup from 'primevue/avatargroup';
const router = useRouter();
const brandName = 'Psi Quasar'; // ajuste para o nome final do produto
const year = computed(() => new Date().getFullYear());
function go(path) {
router.push(path);
}
function scrollTo(id) {
const el = document.getElementById(id);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
const featuredPlanKey = computed(() => {
const list = Array.isArray(pricing.value) ? pricing.value : [];
const featured = list.find((p) => p && p.is_featured && p.is_visible);
return featured?.plan_key || null;
});
function goStart() {
if (featuredPlanKey.value) {
router.push(`/auth/signup?plan=${featuredPlanKey.value}&interval=${billingInterval.value}`);
return;
}
router.push('/auth/signup');
}
const features = ref([
{
title: 'Agenda inteligente',
desc: 'Configure sua semana, encaixes, bloqueios e visão por dia/semana.',
icon: 'pi pi-calendar'
},
{
title: 'Autoagendamento (PRO)',
desc: 'Página para o paciente confirmar, agendar e reagendar sem fricção.',
icon: 'pi pi-globe',
pro: true
},
{
title: 'Prontuário e sessões',
desc: 'Registro por paciente, histórico por sessão e organização por linha do tempo.',
icon: 'pi pi-file-edit'
},
{
title: 'Financeiro integrado',
desc: 'Receitas, despesas e visão do mês conectadas ao que acontece na agenda.',
icon: 'pi pi-wallet'
},
{
title: 'Pacientes e tags',
desc: 'Segmentação por grupos, etiquetas e filtros práticos para achar rápido.',
icon: 'pi pi-users'
},
{
title: 'Clínica / multi-profissional',
desc: 'Múltiplos profissionais, agendas separadas, papéis e visão gerencial.',
icon: 'pi pi-building'
}
]);
/** PRICING dinâmico do SaaS */
const billingInterval = ref('year'); // 'month' | 'year'
const pricing = ref([]);
const loadingPricing = ref(false);
function formatBRLFromCents(cents) {
if (cents == null) return '—';
const v = Number(cents) / 100;
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
function priceFor(p) {
return billingInterval.value === 'year' ? p.yearly_cents : p.monthly_cents;
}
async function fetchPricing() {
loadingPricing.value = true;
const { data, error } = await supabase.from('v_public_pricing').select('*').eq('is_visible', true).order('sort_order', { ascending: true });
loadingPricing.value = false;
if (!error) pricing.value = data || [];
}
onMounted(fetchPricing);
</script>
<template>
<div class="min-h-screen bg-[var(--surface-ground)] text-[var(--text-color)]">
<!-- TOPBAR -->
<div class="sticky top-0 z-40 border-b border-[var(--surface-border)] bg-[color-mix(in_srgb,var(--surface-card),transparent_12%)] backdrop-blur">
<div class="mx-auto max-w-7xl px-4 md:px-6 py-3 flex items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0">
<div class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] grid place-items-center shadow-sm">
<i class="pi pi-sparkles text-lg opacity-80" />
</div>
<div class="min-w-0">
<div class="font-semibold leading-tight truncate">{{ brandName }}</div>
<div class="text-xs text-[var(--text-color-secondary)] truncate">Gestão clínica sem ruído.</div>
</div>
</div>
<div class="flex items-center gap-2">
<Button label="Entrar" icon="pi pi-sign-in" severity="secondary" outlined @click="go('/auth/login')" />
<Button label="Começar" icon="pi pi-bolt" @click="goStart()" />
</div>
</div>
</div>
<!-- HERO -->
<section class="relative overflow-hidden">
<!-- blobs / noir glow -->
<div class="pointer-events-none absolute inset-0">
<div class="absolute -top-28 -left-28 h-96 w-96 rounded-full blur-3xl opacity-60 bg-indigo-400/10" />
<div class="absolute top-24 -right-24 h-[28rem] w-[28rem] rounded-full blur-3xl opacity-60 bg-emerald-400/10" />
<div class="absolute -bottom-40 left-1/3 h-[34rem] w-[34rem] rounded-full blur-3xl opacity-60 bg-fuchsia-400/10" />
</div>
<div class="mx-auto max-w-7xl px-4 md:px-6 pt-10 md:pt-16 pb-8 md:pb-14 relative">
<div class="grid grid-cols-12 gap-6 items-center">
<div class="col-span-12 lg:col-span-7">
<Chip class="mb-4" label="Para psicólogos e clínicas" icon="pi pi-shield" />
<h1 class="text-3xl md:text-5xl font-semibold leading-tight">Uma agenda inteligente, um prontuário organizado, um financeiro respirável.</h1>
<p class="mt-4 text-base md:text-lg text-[var(--text-color-secondary)] max-w-2xl">Centralize a rotina clínica em um lugar : pacientes, sessões, lembretes e indicadores. Menos dispersão. Mais presença.</p>
<div class="mt-6 flex flex-col sm:flex-row gap-2">
<Button label="Criar conta grátis" icon="pi pi-arrow-right" class="w-full sm:w-auto" @click="goStart()" />
<Button label="Ver planos" icon="pi pi-credit-card" severity="secondary" outlined class="w-full sm:w-auto" @click="scrollTo('pricing')" />
</div>
<div class="mt-6 flex flex-wrap gap-2">
<Tag severity="secondary" value="Agenda online (PRO)" />
<Tag severity="secondary" value="Controle de sessões" />
<Tag severity="secondary" value="Financeiro integrado" />
<Tag severity="secondary" value="Clínica / multi-profissional" />
</div>
</div>
<div class="col-span-12 lg:col-span-5">
<Card class="overflow-hidden">
<template #content>
<div class="p-1">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
<div class="flex items-start justify-between gap-3">
<div>
<div class="font-semibold text-lg">Painel de hoje</div>
<div class="text-sm text-[var(--text-color-secondary)]">Um recorte: o essencial, sem excesso.</div>
</div>
<i class="pi pi-chart-line opacity-70" />
</div>
<Divider class="my-4" />
<div class="grid grid-cols-12 gap-3">
<div class="col-span-12 md:col-span-6">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3">
<div class="text-xs text-[var(--text-color-secondary)]">Sessões</div>
<div class="text-2xl font-semibold mt-1">6</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">com lembretes automáticos</div>
</div>
</div>
<div class="col-span-12 md:col-span-6">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3">
<div class="text-xs text-[var(--text-color-secondary)]">Recebimentos</div>
<div class="text-2xl font-semibold mt-1">R$ 840</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">visão clara do mês</div>
</div>
</div>
<div class="col-span-12">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3">
<div class="flex items-center justify-between">
<div>
<div class="text-xs text-[var(--text-color-secondary)]">Prontuário</div>
<div class="font-semibold mt-1">Anotações e histórico</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">organizado por paciente, sessão e linha do tempo</div>
</div>
<i class="pi pi-file-edit opacity-70" />
</div>
</div>
</div>
</div>
<div class="mt-4 text-xs text-[var(--text-color-secondary)]">* Ilustração conceitual do produto.</div>
</div>
</div>
</template>
</Card>
</div>
</div>
</div>
</section>
<!-- TRUST / VALUE STRIP -->
<section class="mx-auto max-w-7xl px-4 md:px-6 pb-10">
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 md:col-span-4">
<Card class="h-full">
<template #content>
<div class="flex items-start gap-3">
<div class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center">
<i class="pi pi-calendar opacity-80" />
</div>
<div>
<div class="font-semibold">Agenda e autoagendamento</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">O paciente confirma, agenda e reagenda com autonomia (PRO).</div>
</div>
</div>
</template>
</Card>
</div>
<div class="col-span-12 md:col-span-4">
<Card class="h-full">
<template #content>
<div class="flex items-start gap-3">
<div class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center">
<i class="pi pi-wallet opacity-80" />
</div>
<div>
<div class="font-semibold">Financeiro integrado</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">Receita/despesa junto da agenda sem planilhas espalhadas.</div>
</div>
</div>
</template>
</Card>
</div>
<div class="col-span-12 md:col-span-4">
<Card class="h-full">
<template #content>
<div class="flex items-start gap-3">
<div class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center">
<i class="pi pi-lock opacity-80" />
</div>
<div>
<div class="font-semibold">Prontuário e controle de sessões</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">Registro clínico e histórico acessíveis, com backups e organização.</div>
</div>
</div>
</template>
</Card>
</div>
</div>
<div class="mt-3 text-xs text-[var(--text-color-secondary)]">Inspirações de módulos comuns no mercado: agenda online, financeiro, prontuário/controle de sessões e gestão de clínica.</div>
</section>
<!-- FEATURES -->
<section class="mx-auto max-w-7xl px-4 md:px-6 pb-12">
<div class="flex items-end justify-between gap-3 mb-4">
<div>
<div class="text-2xl md:text-3xl font-semibold">Recursos que sustentam a rotina</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">O foco é tirar o excesso de fricção sem invadir o que é do seu método.</div>
</div>
<Button label="Ver planos" severity="secondary" outlined icon="pi pi-arrow-down" @click="scrollTo('pricing')" />
</div>
<div class="grid grid-cols-12 gap-4">
<div v-for="f in features" :key="f.title" class="col-span-12 md:col-span-6 lg:col-span-4">
<Card class="h-full">
<template #content>
<div class="flex items-start gap-3">
<div class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center">
<i :class="f.icon" class="opacity-80" />
</div>
<div class="min-w-0">
<div class="font-semibold">{{ f.title }}</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
{{ f.desc }}
</div>
<div v-if="f.pro" class="mt-2">
<Tag severity="warning" value="PRO" />
</div>
</div>
</div>
</template>
</Card>
</div>
</div>
<Divider class="my-8" />
<Accordion :activeIndex="0">
<AccordionTab header="Como fica o fluxo na prática?">
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed">
Você abre a agenda, a sessão acontece, o registro fica no prontuário, e o financeiro acompanha o movimento. O sistema existe para manter o consultório respirando não para virar uma burocracia nova.
</div>
</AccordionTab>
<AccordionTab header="E para clínica (multi-profissionais)?">
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed">Perfis por função, agendas separadas, repasses e visão gerencial quando você estiver pronto para crescer.</div>
</AccordionTab>
<AccordionTab header="Privacidade e segurança">
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed">
Controle de acesso por conta, separação por clínica/tenant, e políticas de storage por usuário. (Os detalhes de conformidade você pode expor numa página própria de segurança/LGPD.)
</div>
</AccordionTab>
</Accordion>
</section>
<!-- PRICING (dinâmico do SaaS) -->
<section id="pricing" class="mx-auto max-w-7xl px-4 md:px-6 pb-14 scroll-mt-24">
<div class="text-5xl md:text-4xl font-semibold text-center">Planos</div>
<div class="text-2xl md:text-2xl text-[var(--text-color-secondary)] mt-1 text-center">Comece simples. Suba para PRO quando a agenda pedir automação.</div>
<!-- header conceitual + toggle -->
<div class="flex flex-col items-center text-center mt-6">
<div class="flex items-center gap-3 mb-4">
<AvatarGroup>
<Avatar image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-m-1.png" shape="circle" />
<Avatar image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-f-21.png" shape="circle" />
<Avatar image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-f-1.png" shape="circle" />
<Avatar image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-m-3.png" shape="circle" />
</AvatarGroup>
<Divider layout="vertical" />
<span class="text-sm text-[var(--text-color-secondary)] font-medium">Happy Customers</span>
</div>
<div class="inline-flex items-center rounded-xl border border-[var(--surface-border)] bg-[var(--surface-50)] p-1">
<Button label="Mensal" size="small" :severity="billingInterval === 'month' ? 'success' : 'secondary'" :outlined="billingInterval !== 'month'" @click="billingInterval = 'month'" />
<Button label="Anual" size="small" :severity="billingInterval === 'year' ? 'success' : 'secondary'" :outlined="billingInterval !== 'year'" class="ml-1" @click="billingInterval = 'year'" />
</div>
<div v-if="billingInterval === 'year'" class="mt-2">
<Tag severity="success" value="Economize até 20%" />
</div>
</div>
<div v-if="loadingPricing" class="mt-8 text-sm text-[var(--text-color-secondary)]">Carregando planos...</div>
<div v-else class="mt-8 grid grid-cols-12 gap-4">
<div v-for="p in pricing" :key="p.plan_id" class="col-span-12 md:col-span-4">
<Card class="h-full overflow-hidden transition-transform" :class="p.is_featured ? 'ring-1 ring-emerald-500/30 md:-translate-y-2 md:scale-[1.02]' : ''">
<template #content>
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm text-[var(--text-color-secondary)]">
{{ p.badge || 'Plano' }}
</div>
<div class="text-xl font-semibold">
{{ p.public_name || p.plan_name || p.plan_key }}
</div>
</div>
<Tag v-if="p.is_featured" severity="success" value="Popular" />
</div>
<div class="mt-4 text-3xl font-semibold leading-none">
{{ formatBRLFromCents(priceFor(p)) }}
<span class="text-sm font-normal text-[var(--text-color-secondary)]"> /{{ billingInterval === 'month' ? 'mês' : 'ano' }} </span>
</div>
<div v-if="billingInterval === 'year'" class="text-xs text-emerald-500 mt-1 font-medium">Melhor custo-benefício</div>
<div class="mt-2 text-sm text-[var(--text-color-secondary)] min-h-[44px]">
{{ p.public_description }}
</div>
<Divider class="my-4" />
<ul v-if="p.bullets?.length" class="space-y-2 text-sm">
<li v-for="b in p.bullets" :key="b.id" class="flex items-start gap-2">
<i class="pi pi-check mt-1 text-emerald-500"></i>
<span :class="b.highlight ? 'font-semibold' : 'text-[var(--text-color-secondary)]'">
{{ b.text }}
</span>
</li>
</ul>
<div v-else class="text-sm text-[var(--text-color-secondary)]">Benefícios em breve.</div>
<div class="mt-5">
<Button
label="Começar"
class="w-full"
:severity="p.is_featured ? 'success' : 'secondary'"
:outlined="!p.is_featured"
icon="pi pi-arrow-right"
@click="go(`/auth/signup?plan=${p.plan_key}&interval=${billingInterval}`)"
/>
</div>
</template>
</Card>
</div>
</div>
<div class="mt-6 text-xs text-[var(--text-color-secondary)]">Dica: estes planos vêm do painel SaaS (vitrine pública) e podem mapear diretamente para entitlements (FREE/PRO) sem mexer no código.</div>
</section>
<!-- FOOTER -->
<footer class="border-t border-[var(--surface-border)]">
<div class="mx-auto max-w-7xl px-4 md:px-6 py-8 flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
<div>
<div class="font-semibold">{{ brandName }}</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">© {{ year }} Todos os direitos reservados.</div>
</div>
<div class="flex flex-wrap gap-2">
<Button label="Entrar" severity="secondary" outlined icon="pi pi-sign-in" @click="go('/auth/login')" />
<Button label="Criar conta" icon="pi pi-bolt" @click="goStart()" />
</div>
</div>
</footer>
</div>
</template>
@@ -1,142 +0,0 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/views/pages/public/PatientsExternalLinkPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { computed, onMounted, ref } from 'vue';
import Message from 'primevue/message';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client'; // ajuste se seu caminho for diferente
const toast = useToast();
const inviteToken = ref('');
const rotating = ref(false);
const origin = computed(() => window.location.origin);
const publicUrl = computed(() => {
if (!inviteToken.value) return '';
return `${origin.value}/cadastro/paciente?t=${inviteToken.value}`;
});
function newToken() {
// browsers modernos
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
// fallback simples
return 'tok_' + Math.random().toString(36).slice(2) + Date.now().toString(36);
}
async function requireUserId() {
const { data, error } = await supabase.auth.getUser();
if (error) throw error;
const uid = data?.user?.id;
if (!uid) throw new Error('Usuário não autenticado');
return uid;
}
async function loadOrCreateInvite() {
const uid = await requireUserId();
const { data, error } = await supabase.from('patient_invites').select('token, active').eq('owner_id', uid).eq('active', true).order('created_at', { ascending: false }).limit(1);
if (error) throw error;
const token = data?.[0]?.token;
if (token) {
inviteToken.value = token;
return;
}
const t = newToken();
const { error: insErr } = await supabase.from('patient_invites').insert({ owner_id: uid, token: t, active: true });
if (insErr) throw insErr;
inviteToken.value = t;
}
async function rotateLink() {
rotating.value = true;
try {
const t = newToken();
const { error } = await supabase.rpc('rotate_patient_invite_token', { p_new_token: t });
if (error) throw error;
inviteToken.value = t;
toast.add({ severity: 'success', summary: 'Pronto', detail: 'Novo link gerado.', life: 2000 });
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao gerar novo link.', life: 3500 });
} finally {
rotating.value = false;
}
}
async function copyLink() {
try {
if (!publicUrl.value) return;
await navigator.clipboard.writeText(publicUrl.value);
toast.add({ severity: 'success', summary: 'Copiado', detail: 'Link copiado.', life: 1500 });
} catch {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Não foi possível copiar automaticamente.', life: 2500 });
}
}
function openLink() {
if (!publicUrl.value) return;
window.open(publicUrl.value, '_blank', 'noopener');
}
onMounted(async () => {
try {
await loadOrCreateInvite();
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao carregar link.', life: 3500 });
}
});
</script>
<template>
<div class="p-4">
<!-- HEADER -->
<div class="flex flex-column md:flex-row md:align-items-center md:justify-content-between gap-3">
<div>
<div class="text-900 text-2xl font-semibold">Cadastro Externo</div>
<div class="text-600 mt-1">Gere um link para o paciente preencher o pré-cadastro.</div>
</div>
<div class="flex gap-2">
<Button label="Gerar novo link" icon="pi pi-refresh" severity="secondary" outlined :loading="rotating" @click="rotateLink" />
</div>
</div>
<!-- CARD -->
<Card class="mt-4">
<template #title>Seu link</template>
<template #subtitle>Envie este link ao paciente.</template>
<template #content>
<div class="flex flex-column gap-3">
<div class="p-inputgroup">
<InputText readonly :value="publicUrl" placeholder="Gerando seu link…" />
<Button icon="pi pi-copy" severity="secondary" outlined :disabled="!publicUrl" @click="copyLink" v-tooltip.bottom="'Copiar'" />
<Button icon="pi pi-external-link" severity="secondary" outlined :disabled="!publicUrl" @click="openLink" v-tooltip.bottom="'Abrir'" />
</div>
<Message v-if="!inviteToken" severity="info" :closable="false"> Gerando seu link... </Message>
</div>
</template>
</Card>
</div>
</template>