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(() => {