freemium F3: frontend dos extras (usuarios, esqueci-email, root_redirect, sino)

- SaasUsuariosPage + rota /saas/usuarios + menu: 1 linha/tenant com dono/slug/
  email/plano, realce verde + selo "Novo" 24h (saas_list_account_owners)
- esqueci-email no Login: dialog que chama a edge recover-access (acha dono por
  slug, manda magic link, mostra so dica mascarada). Edge function recover-access.
- root_redirect: guard roteia "/" do visitante nao-logado pra /lp ou /auth/login
  conforme get_root_redirect (cache TTL 5min)
- pegadinha #4: notificationStore.reset() no logout (limpa sino ao trocar user)
- build OK

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-06-13 20:21:46 -03:00
parent 03790ecb9e
commit d50073da1a
8 changed files with 320 additions and 1 deletions
+64 -1
View File
@@ -45,6 +45,13 @@ const recoveryEmail = ref('');
const loadingRecovery = ref(false);
const recoverySent = ref(false);
// Freemium F3d: "esqueci meu e-mail" — recupera por slug (identificador)
const openRecoverEmail = ref(false);
const recoverSlug = ref('');
const recoverHint = ref('');
const recoverDone = ref(false);
const loadingRecoverEmail = ref(false);
// carrossel
const SLIDES_FALLBACK = [
{
@@ -293,6 +300,37 @@ async function sendRecoveryEmail() {
}
}
function openForgotEmail() {
recoverSlug.value = '';
recoverHint.value = '';
recoverDone.value = false;
openRecoverEmail.value = true;
}
async function recoverEmailBySlug() {
const slug = String(recoverSlug.value || '').trim().toLowerCase();
if (slug.length < 3) {
toast.add({ severity: 'warn', summary: 'Identificador', detail: 'Informe o identificador do seu ambiente.', life: 3000 });
return;
}
loadingRecoverEmail.value = true;
recoverDone.value = false;
try {
const { data, error } = await supabase.functions.invoke('recover-access', { body: { slug } });
if (error) throw error;
if (data?.ok && data?.hint) {
recoverHint.value = data.hint;
recoverDone.value = true;
} else {
toast.add({ severity: 'warn', summary: 'Não encontrado', detail: 'Nenhum ambiente com esse identificador.', life: 4000 });
}
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao recuperar acesso.', life: 4500 });
} finally {
loadingRecoverEmail.value = false;
}
}
onMounted(async () => {
await loadCarouselSlides();
@@ -456,7 +494,10 @@ onBeforeUnmount(() => {
<Checkbox v-model="checked" inputId="rememberme1" binary :disabled="loading || loadingRecovery" />
<label for="rememberme1" class="text-sm text-[var(--text-color-secondary)] cursor-pointer select-none"> Lembrar e-mail </label>
</div>
<button type="button" class="text-sm font-medium text-indigo-500 hover:text-indigo-600 transition-colors" :disabled="loading || loadingRecovery" @click="openForgot">Esqueceu a senha?</button>
<div class="flex items-center gap-3">
<button type="button" class="text-sm font-medium text-[var(--text-color-secondary)] hover:text-[var(--text-color)] transition-colors" :disabled="loading || loadingRecovery" @click="openForgotEmail">Esqueci meu e-mail</button>
<button type="button" class="text-sm font-medium text-indigo-500 hover:text-indigo-600 transition-colors" :disabled="loading || loadingRecovery" @click="openForgot">Esqueceu a senha?</button>
</div>
</div>
<!-- Erro -->
@@ -507,6 +548,28 @@ onBeforeUnmount(() => {
</div>
</div>
</Dialog>
<!-- Dialog: Esqueci meu e-mail (recupera por slug) -->
<Dialog v-model:visible="openRecoverEmail" modal header="Esqueci meu e-mail" :draggable="false" :style="{ width: '28rem', maxWidth: '92vw' }">
<div class="space-y-3">
<div class="text-sm text-[var(--text-color-secondary)]">Informe o <b>identificador</b> do seu ambiente (aquele que você escolheu no cadastro). Enviamos um link de acesso pro e-mail do dono.</div>
<div class="space-y-2">
<label class="text-sm font-semibold">Identificador</label>
<InputText v-model="recoverSlug" class="w-full" placeholder="meu_consultorio" :disabled="loadingRecoverEmail" @keydown.enter.prevent="recoverEmailBySlug" />
</div>
<div class="flex flex-col gap-2 sm:flex-row sm:justify-end pt-2">
<Button label="Cancelar" severity="secondary" outlined :disabled="loadingRecoverEmail" @click="openRecoverEmail = false" />
<Button label="Enviar link de acesso" icon="pi pi-envelope" :loading="loadingRecoverEmail" @click="recoverEmailBySlug" />
</div>
<div v-if="recoverDone" class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-3 text-xs text-[var(--text-color-secondary)]">
<i class="pi pi-check mr-2 text-emerald-500" />
Enviamos um link de acesso para <b>{{ recoverHint }}</b>. Abra o e-mail e clique no link pra entrar.
</div>
</div>
</Dialog>
</template>
<style scoped>
+115
View File
@@ -0,0 +1,115 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI SaasUsuariosPage (Freemium F3b)
|--------------------------------------------------------------------------
| 1 linha por tenant com o DONO (master): nome, slug, e-mail principal, plano.
| Realce verde + selo "Novo" pra clientes criados nas últimas 24h. Dev-only
| (RPC saas_list_account_owners é gated por is_saas_admin).
|--------------------------------------------------------------------------
-->
<script setup>
import { onMounted, ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useToast } from 'primevue/usetoast';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import Tag from 'primevue/tag';
import InputText from 'primevue/inputtext';
const toast = useToast();
const rows = ref([]);
const loading = ref(false);
const filtro = ref('');
const filtered = computed(() => {
const q = filtro.value.trim().toLowerCase();
if (!q) return rows.value;
return rows.value.filter((r) =>
[r.tenant_name, r.slug, r.owner_name, r.owner_email, r.plan_key]
.some((v) => String(v || '').toLowerCase().includes(q))
);
});
const novos24h = computed(() => rows.value.filter((r) => r.is_new).length);
function planSeverity(plan) {
const p = String(plan || '').toLowerCase();
if (!p) return 'secondary';
return p.endsWith('_free') ? 'info' : 'success';
}
function rowClass(data) {
return data?.is_new ? 'row-novo' : '';
}
function fmtDate(d) {
if (!d) return '—';
try { return new Date(d).toLocaleString('pt-BR'); } catch { return d; }
}
async function load() {
loading.value = true;
try {
const { data, error } = await supabase.rpc('saas_list_account_owners');
if (error) throw error;
rows.value = Array.isArray(data) ? data : [];
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao carregar.', life: 5000 });
rows.value = [];
} finally {
loading.value = false;
}
}
onMounted(load);
</script>
<template>
<div class="p-4">
<div class="flex items-center justify-between gap-3 flex-wrap mb-4">
<div>
<h1 class="text-2xl font-semibold">Usuários / Donos</h1>
<p class="text-sm text-[var(--text-color-secondary)]">Um por cliente (tenant), com o dono e o plano ativo.</p>
</div>
<div class="flex items-center gap-2">
<Tag v-if="novos24h" severity="success" :value="`${novos24h} novo${novos24h === 1 ? '' : 's'} (24h)`" />
<span class="p-input-icon-left">
<InputText v-model="filtro" placeholder="Buscar nome, slug, e-mail…" class="w-72" />
</span>
</div>
</div>
<DataTable :value="filtered" :loading="loading" :rowClass="rowClass" paginator :rows="20" dataKey="tenant_id" stripedRows size="small" responsiveLayout="scroll">
<Column field="tenant_name" header="Cliente" sortable>
<template #body="{ data }">
<div class="flex items-center gap-2">
<span class="font-medium">{{ data.tenant_name || '—' }}</span>
<Tag v-if="data.is_new" severity="success" value="Novo" />
</div>
</template>
</Column>
<Column field="slug" header="Identificador" sortable />
<Column field="owner_name" header="Dono" sortable>
<template #body="{ data }">{{ data.owner_name || '—' }}</template>
</Column>
<Column field="owner_email" header="E-mail" sortable>
<template #body="{ data }">{{ data.owner_email || '—' }}</template>
</Column>
<Column field="plan_key" header="Plano" sortable>
<template #body="{ data }">
<Tag :severity="planSeverity(data.plan_key)" :value="data.plan_key || 'sem plano'" />
</template>
</Column>
<Column field="created_at" header="Criado em" sortable>
<template #body="{ data }">{{ fmtDate(data.created_at) }}</template>
</Column>
</DataTable>
</div>
</template>
<style scoped>
:deep(.row-novo) {
background: color-mix(in srgb, var(--p-green-500), transparent 88%) !important;
}
</style>