diff --git a/src/layout/AppTopbar.vue b/src/layout/AppTopbar.vue index 1f5292f..a350ae4 100644 --- a/src/layout/AppTopbar.vue +++ b/src/layout/AppTopbar.vue @@ -548,6 +548,7 @@ async function logout() { tenant.reset(); ent.invalidate(); tf.invalidate(); + notificationStore.reset(); // pegadinha #4: limpa sino ao trocar de usuário sessionStorage.clear(); localStorage.clear(); diff --git a/src/navigation/menus/saas.menu.js b/src/navigation/menus/saas.menu.js index 65bd95b..8debc7f 100644 --- a/src/navigation/menus/saas.menu.js +++ b/src/navigation/menus/saas.menu.js @@ -60,6 +60,7 @@ export default function saasMenu(sessionCtx, opts = {}) { { label: 'Operações', 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: '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' }, diff --git a/src/router/guards.js b/src/router/guards.js index efcead1..67e355f 100644 --- a/src/router/guards.js +++ b/src/router/guards.js @@ -43,6 +43,23 @@ let sessionUidCache = null; let saasAdminCacheUid = 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. // 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. @@ -323,6 +340,18 @@ export function applyGuards(router) { 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 if (!to.meta?.requiresAuth) { _perfEnd(); diff --git a/src/router/routes.saas.js b/src/router/routes.saas.js index 6c7f983..dda064f 100644 --- a/src/router/routes.saas.js +++ b/src/router/routes.saas.js @@ -166,6 +166,12 @@ export default { name: 'saas-desenvolvimento', component: () => import('@/views/pages/saas/development/SaasDevelopmentPage.vue'), meta: { requiresAuth: true, saasAdmin: true } + }, + { + path: 'usuarios', + name: 'saas-usuarios', + component: () => import('@/views/pages/saas/SaasUsuariosPage.vue'), + meta: { requiresAuth: true, saasAdmin: true } } ] }; diff --git a/src/stores/notificationStore.js b/src/stores/notificationStore.js index aadfdd0..2ce3f8a 100644 --- a/src/stores/notificationStore.js +++ b/src/stores/notificationStore.js @@ -215,6 +215,15 @@ export const useNotificationStore = defineStore('notifications', { supabase.removeChannel(this._channel); 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; } } }); diff --git a/src/views/pages/auth/Login.vue b/src/views/pages/auth/Login.vue index 6357ccf..f342ead 100644 --- a/src/views/pages/auth/Login.vue +++ b/src/views/pages/auth/Login.vue @@ -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(() => { - +
+ + +
@@ -507,6 +548,28 @@ onBeforeUnmount(() => { + + + +
+
Informe o identificador do seu ambiente (aquele que você escolheu no cadastro). Enviamos um link de acesso pro e-mail do dono.
+ +
+ + +
+ +
+
+ +
+ + Enviamos um link de acesso para {{ recoverHint }}. Abra o e-mail e clique no link pra entrar. +
+
+
diff --git a/supabase/functions/recover-access/index.ts b/supabase/functions/recover-access/index.ts new file mode 100644 index 0000000..0ed5727 --- /dev/null +++ b/supabase/functions/recover-access/index.ts @@ -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) + } +})