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:
@@ -548,6 +548,7 @@ async function logout() {
|
|||||||
tenant.reset();
|
tenant.reset();
|
||||||
ent.invalidate();
|
ent.invalidate();
|
||||||
tf.invalidate();
|
tf.invalidate();
|
||||||
|
notificationStore.reset(); // pegadinha #4: limpa sino ao trocar de usuário
|
||||||
|
|
||||||
sessionStorage.clear();
|
sessionStorage.clear();
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export default function saasMenu(sessionCtx, opts = {}) {
|
|||||||
{
|
{
|
||||||
label: 'Operações',
|
label: 'Operações',
|
||||||
items: [
|
items: [
|
||||||
|
{ label: 'Usuários / Donos', icon: 'pi pi-fw pi-id-card', to: '/saas/usuarios' },
|
||||||
{ label: 'Clínicas (Tenants)', icon: 'pi pi-fw pi-users', to: '/saas/tenants' },
|
{ label: 'Clínicas (Tenants)', icon: 'pi pi-fw pi-users', to: '/saas/tenants' },
|
||||||
{ label: 'Recursos por Clínica', icon: 'pi pi-fw pi-key', to: '/saas/tenant-features' },
|
{ label: 'Recursos por Clínica', icon: 'pi pi-fw pi-key', to: '/saas/tenant-features' },
|
||||||
{ label: 'Segurança / Bots', icon: 'pi pi-fw pi-shield', to: '/saas/security' },
|
{ label: 'Segurança / Bots', icon: 'pi pi-fw pi-shield', to: '/saas/security' },
|
||||||
|
|||||||
@@ -43,6 +43,23 @@ let sessionUidCache = null;
|
|||||||
let saasAdminCacheUid = null;
|
let saasAdminCacheUid = null;
|
||||||
let saasAdminCacheIsAdmin = null;
|
let saasAdminCacheIsAdmin = null;
|
||||||
|
|
||||||
|
// Freemium F3c — cache do root_redirect (TTL 5min) pra não bater no DB a cada "/".
|
||||||
|
let rootRedirectCache = null;
|
||||||
|
let rootRedirectCacheAt = 0;
|
||||||
|
async function getRootRedirectCached() {
|
||||||
|
const TTL = 5 * 60 * 1000;
|
||||||
|
if (rootRedirectCache && Date.now() - rootRedirectCacheAt < TTL) return rootRedirectCache;
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase.rpc('get_root_redirect');
|
||||||
|
if (error) throw error;
|
||||||
|
rootRedirectCache = data === 'login' ? 'login' : 'landing';
|
||||||
|
rootRedirectCacheAt = Date.now();
|
||||||
|
} catch {
|
||||||
|
rootRedirectCache = 'landing'; // fallback seguro
|
||||||
|
}
|
||||||
|
return rootRedirectCache;
|
||||||
|
}
|
||||||
|
|
||||||
// V#6 — cache de globalRole por uid com TTL.
|
// V#6 — cache de globalRole por uid com TTL.
|
||||||
// Antes era invalidado apenas em SIGNED_IN/SIGNED_OUT, ficando stale se a role
|
// Antes era invalidado apenas em SIGNED_IN/SIGNED_OUT, ficando stale se a role
|
||||||
// mudasse durante a sessão. TTL de 5min força re-fetch periódico.
|
// mudasse durante a sessão. TTL de 5min força re-fetch periódico.
|
||||||
@@ -323,6 +340,18 @@ export function applyGuards(router) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ✅ Freemium F3c: raiz "/" do visitante NÃO-logado vai pra landing
|
||||||
|
// (/lp) ou login conforme root_redirect (config saas). Logado segue
|
||||||
|
// pro fluxo normal (HomeCards).
|
||||||
|
if (to.path === '/') {
|
||||||
|
await waitSessionIfRefreshing();
|
||||||
|
if (!sessionUser.value?.id) {
|
||||||
|
const target = await getRootRedirectCached();
|
||||||
|
_perfEnd();
|
||||||
|
return target === 'login' ? { path: '/auth/login' } : { path: '/lp' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// se rota não exige auth, libera
|
// se rota não exige auth, libera
|
||||||
if (!to.meta?.requiresAuth) {
|
if (!to.meta?.requiresAuth) {
|
||||||
_perfEnd();
|
_perfEnd();
|
||||||
|
|||||||
@@ -166,6 +166,12 @@ export default {
|
|||||||
name: 'saas-desenvolvimento',
|
name: 'saas-desenvolvimento',
|
||||||
component: () => import('@/views/pages/saas/development/SaasDevelopmentPage.vue'),
|
component: () => import('@/views/pages/saas/development/SaasDevelopmentPage.vue'),
|
||||||
meta: { requiresAuth: true, saasAdmin: true }
|
meta: { requiresAuth: true, saasAdmin: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'usuarios',
|
||||||
|
name: 'saas-usuarios',
|
||||||
|
component: () => import('@/views/pages/saas/SaasUsuariosPage.vue'),
|
||||||
|
meta: { requiresAuth: true, saasAdmin: true }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -215,6 +215,15 @@ export const useNotificationStore = defineStore('notifications', {
|
|||||||
supabase.removeChannel(this._channel);
|
supabase.removeChannel(this._channel);
|
||||||
this._channel = null;
|
this._channel = null;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ⚠️ Pegadinha #4: ao trocar de usuário (logout→login), o store é um
|
||||||
|
// singleton Pinia — sem reset, items/_channel ficam stale e vazam
|
||||||
|
// notificações entre usuários. Chamar no logout.
|
||||||
|
reset() {
|
||||||
|
this.unsubscribe();
|
||||||
|
this.items = [];
|
||||||
|
this.drawerOpen = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,6 +45,13 @@ const recoveryEmail = ref('');
|
|||||||
const loadingRecovery = ref(false);
|
const loadingRecovery = ref(false);
|
||||||
const recoverySent = 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
|
// carrossel
|
||||||
const SLIDES_FALLBACK = [
|
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 () => {
|
onMounted(async () => {
|
||||||
await loadCarouselSlides();
|
await loadCarouselSlides();
|
||||||
|
|
||||||
@@ -456,7 +494,10 @@ onBeforeUnmount(() => {
|
|||||||
<Checkbox v-model="checked" inputId="rememberme1" binary :disabled="loading || loadingRecovery" />
|
<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>
|
<label for="rememberme1" class="text-sm text-[var(--text-color-secondary)] cursor-pointer select-none"> Lembrar e-mail </label>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Erro -->
|
<!-- Erro -->
|
||||||
@@ -507,6 +548,28 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</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>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Agência PSI — Edge Function: recover-access (Freemium F3d)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| "Esqueci meu e-mail": a pessoa informa o IDENTIFICADOR (slug) do seu
|
||||||
|
| ambiente. O servidor acha o e-mail do dono, dispara um magic link
|
||||||
|
| (signInWithOtp — mesmo pipeline de e-mail do GoTrue) e devolve só uma DICA
|
||||||
|
| MASCARADA (jo****@gm****.com). O e-mail real NUNCA volta pro cliente.
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
|
||||||
|
|
||||||
|
const corsHeaders = {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||||
|
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||||
|
}
|
||||||
|
|
||||||
|
function json(body: unknown, status = 200) {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// jo****@gm****.com
|
||||||
|
function maskPart(s: string): string {
|
||||||
|
if (!s) return '*'
|
||||||
|
if (s.length <= 2) return s[0] + '***'
|
||||||
|
return s.slice(0, 2) + '****'
|
||||||
|
}
|
||||||
|
function maskEmail(email: string): string {
|
||||||
|
const [local, domain] = String(email).split('@')
|
||||||
|
if (!domain) return maskPart(local)
|
||||||
|
const dparts = domain.split('.')
|
||||||
|
const dmasked = maskPart(dparts[0]) + (dparts.length > 1 ? '.' + dparts.slice(1).join('.') : '')
|
||||||
|
return maskPart(local) + '@' + dmasked
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.serve(async (req: Request) => {
|
||||||
|
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
|
||||||
|
if (req.method !== 'POST') return json({ ok: false, error: 'method_not_allowed' }, 405)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { slug } = await req.json().catch(() => ({}))
|
||||||
|
const cleanSlug = String(slug || '').toLowerCase().trim()
|
||||||
|
if (!cleanSlug || cleanSlug.length < 3) {
|
||||||
|
return json({ ok: false, error: 'slug_required' }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const admin = createClient(
|
||||||
|
Deno.env.get('SUPABASE_URL')!,
|
||||||
|
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
|
||||||
|
)
|
||||||
|
|
||||||
|
// slug → tenant → dono (master) → user_id
|
||||||
|
const { data: tenant } = await admin.from('tenants').select('id').eq('slug', cleanSlug).maybeSingle()
|
||||||
|
if (!tenant) return json({ ok: false, error: 'not_found' })
|
||||||
|
|
||||||
|
const { data: member } = await admin
|
||||||
|
.from('tenant_members')
|
||||||
|
.select('user_id')
|
||||||
|
.eq('tenant_id', tenant.id)
|
||||||
|
.eq('role', 'tenant_admin')
|
||||||
|
.eq('status', 'active')
|
||||||
|
.order('created_at', { ascending: true })
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle()
|
||||||
|
if (!member?.user_id) return json({ ok: false, error: 'not_found' })
|
||||||
|
|
||||||
|
const { data: userResp } = await admin.auth.admin.getUserById(member.user_id)
|
||||||
|
const email = userResp?.user?.email
|
||||||
|
if (!email) return json({ ok: false, error: 'not_found' })
|
||||||
|
|
||||||
|
// dispara o magic link via GoTrue (cliente anon — não cria usuário novo)
|
||||||
|
const anon = createClient(
|
||||||
|
Deno.env.get('SUPABASE_URL')!,
|
||||||
|
Deno.env.get('SUPABASE_ANON_KEY')!
|
||||||
|
)
|
||||||
|
const { error: otpErr } = await anon.auth.signInWithOtp({
|
||||||
|
email,
|
||||||
|
options: { shouldCreateUser: false },
|
||||||
|
})
|
||||||
|
if (otpErr) {
|
||||||
|
console.error('[recover-access] signInWithOtp error:', otpErr.message)
|
||||||
|
// ainda devolve a dica — o e-mail existe; o envio pode reprocessar
|
||||||
|
}
|
||||||
|
|
||||||
|
// só a DICA mascarada volta pro cliente
|
||||||
|
return json({ ok: true, hint: maskEmail(email) })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[recover-access] fatal:', err)
|
||||||
|
return json({ ok: false, error: 'internal' }, 500)
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user