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:
@@ -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>
|
||||
Reference in New Issue
Block a user