first commit

This commit is contained in:
Leonardo
2026-02-18 22:36:45 -03:00
parent ec6b6ef53a
commit 676042268b
122 changed files with 26354 additions and 1615 deletions

112
src/app/bootstrapUserSettings.js vendored Normal file
View File

@@ -0,0 +1,112 @@
import { supabase } from '@/lib/supabase/client'
import { useLayout } from '@/layout/composables/layout'
import { $t, updatePreset, updateSurfacePalette } from '@primeuix/themes'
import Aura from '@primeuix/themes/aura'
import Lara from '@primeuix/themes/lara'
import Nora from '@primeuix/themes/nora'
const presets = { Aura, Lara, Nora }
function safeEq (a, b) {
return String(a || '').trim() === String(b || '').trim()
}
// copia do seu getPresetExt (ou exporta ele do Perfil pra reutilizar)
function getPresetExt(primaryColors, layoutConfig) {
const color = primaryColors.find((c) => c.name === layoutConfig.primary) || { name: 'noir', palette: {} }
if (color.name === 'noir') {
return {
semantic: {
primary: {
50: '{surface.50}', 100: '{surface.100}', 200: '{surface.200}', 300: '{surface.300}',
400: '{surface.400}', 500: '{surface.500}', 600: '{surface.600}', 700: '{surface.700}',
800: '{surface.800}', 900: '{surface.900}', 950: '{surface.950}'
},
colorScheme: {
light: {
primary: { color: '{primary.950}', contrastColor: '#ffffff', hoverColor: '{primary.800}', activeColor: '{primary.700}' },
highlight: { background: '{primary.950}', focusBackground: '{primary.700}', color: '#ffffff', focusColor: '#ffffff' }
},
dark: {
primary: { color: '{primary.50}', contrastColor: '{primary.950}', hoverColor: '{primary.200}', activeColor: '{primary.300}' },
highlight: { background: '{primary.50}', focusBackground: '{primary.300}', color: '{primary.950}', focusColor: '{primary.950}' }
}
}
}
}
}
return {
semantic: {
primary: color.palette,
colorScheme: {
light: {
primary: { color: '{primary.500}', contrastColor: '#ffffff', hoverColor: '{primary.600}', activeColor: '{primary.700}' },
highlight: { background: '{primary.50}', focusBackground: '{primary.100}', color: '{primary.700}', focusColor: '{primary.800}' }
},
dark: {
primary: { color: '{primary.400}', contrastColor: '{surface.900}', hoverColor: '{primary.300}', activeColor: '{primary.200}' },
highlight: {
background: 'color-mix(in srgb, {primary.400}, transparent 84%)',
focusBackground: 'color-mix(in srgb, {primary.400}, transparent 76%)',
color: 'rgba(255,255,255,.87)',
focusColor: 'rgba(255,255,255,.87)'
}
}
}
}
}
}
export async function bootstrapUserSettings({
primaryColors = [], // passe a lista do seu Perfil (ou uma versão reduzida)
surfaces = [] // idem
} = {}) {
const { layoutConfig, isDarkTheme, toggleDarkMode, changeMenuMode } = useLayout()
const { data: uRes, error: uErr } = await supabase.auth.getUser()
if (uErr) return
const user = uRes?.user
if (!user) return
const { data: settings, error } = await supabase
.from('user_settings')
.select('theme_mode, preset, primary_color, surface_color, menu_mode')
.eq('user_id', user.id)
.maybeSingle()
if (error || !settings) return
// menu mode
if (settings.menu_mode && settings.menu_mode !== layoutConfig.menuMode) {
layoutConfig.menuMode = settings.menu_mode
changeMenuMode()
}
// preset
if (settings.preset && settings.preset !== layoutConfig.preset) {
layoutConfig.preset = settings.preset
const presetValue = presets[settings.preset] || presets.Aura
const surfacePalette = surfaces.find(s => s.name === layoutConfig.surface)?.palette
$t().preset(presetValue).preset(getPresetExt(primaryColors, layoutConfig)).surfacePalette(surfacePalette).use({ useDefaultOptions: true })
}
// colors
if (settings.primary_color && !safeEq(settings.primary_color, layoutConfig.primary)) {
layoutConfig.primary = settings.primary_color
updatePreset(getPresetExt(primaryColors, layoutConfig))
}
if (settings.surface_color && !safeEq(settings.surface_color, layoutConfig.surface)) {
layoutConfig.surface = settings.surface_color
const surface = surfaces.find(s => s.name === settings.surface_color)
if (surface) updateSurfacePalette(surface.palette)
}
// dark/light
if (settings.theme_mode) {
const shouldBeDark = settings.theme_mode === 'dark'
if (shouldBeDark !== isDarkTheme) toggleDarkMode()
}
}

222
src/app/session.js Normal file
View File

@@ -0,0 +1,222 @@
import { ref } from 'vue'
import { supabase } from '@/lib/supabase/client'
/**
* ⚠️ IMPORTANTE — ESTABILIDADE DE NAVEGAÇÃO
*
* Este módulo controla a sessão global usada pelo router guard.
*
* Já houve uma condição de corrida entre:
* - visibilitychange → refreshSession()
* - supabase.auth.onAuthStateChange (SIGNED_IN)
* - router.beforeEach
*
* Quando a aba voltava ao foco, refreshSession() era disparado,
* o Supabase emitia SIGNED_IN redundante, e o guard aguardava
* tenant + entitlements simultaneamente à re-hidratação da sessão.
*
* Isso fazia a navegação "travar" no meio do beforeEach.
*
* Para evitar isso:
* - initSession usa singleflight (initPromise)
* - refreshSession não roda se já estiver refreshing
* - SIGNED_IN redundante é ignorado quando estado já está consistente
*
* NÃO remover esses controles sem entender o fluxo completo
* entre sessão, guard e carregamento de tenant/entitlements.
*/
export const sessionUser = ref(null)
export const sessionRole = ref(null)
export const sessionIsSaasAdmin = ref(false)
// só no primeiro boot
export const sessionReady = ref(false)
// refresh leve (troca de aba / refresh token) sem desmontar UI
export const sessionRefreshing = ref(false)
let onSignedOutCallback = null
export function setOnSignedOut (cb) {
onSignedOutCallback = typeof cb === 'function' ? cb : null
}
// evita init concorrente
let initPromise = null
async function fetchRole (userId) {
const { data, error } = await supabase
.from('profiles')
.select('role')
.eq('id', userId)
.single()
if (error) return null
return data?.role || null
}
async function fetchIsSaasAdmin (userId) {
const { data, error } = await supabase
.from('saas_admins')
.select('user_id')
.eq('user_id', userId)
.maybeSingle()
if (error) return false
return !!data
}
/**
* Atualiza estado a partir de uma session "confiável" (getSession() ou callback do auth).
* ⚠️ NÃO zera user/role durante refresh enquanto existir sessão.
*/
async function hydrateFromSession (sess) {
const user = sess?.user || null
if (!user?.id) return false
const prevUid = sessionUser.value?.id || null
const uid = user.id
// ✅ pega primeiro hydrate e troca de usuário
const userChanged = prevUid !== uid
// atualiza user imediatamente (sem flicker)
sessionUser.value = user
// ✅ saas admin: calcula no primeiro hydrate e sempre que trocar de user
// (no primeiro hydrate prevUid é null, então userChanged = true)
if (userChanged) {
sessionIsSaasAdmin.value = await fetchIsSaasAdmin(uid)
}
// role: busca se não tem, ou se mudou user
if (!sessionRole.value || userChanged) {
sessionRole.value = await fetchRole(uid)
}
return true
}
/**
* Boot inicial (pode bloquear UI) ou refresh (não pode derrubar menu).
*/
export async function initSession ({ initial = false } = {}) {
if (initPromise) return initPromise
if (initial) sessionReady.value = false
else sessionRefreshing.value = true
initPromise = (async () => {
try {
const { data, error } = await supabase.auth.getSession()
if (error) throw error
const sess = data?.session || null
const ok = await hydrateFromSession(sess)
// se não tem sessão, zera estado (aqui pode, porque é init/refresh controlado)
if (!ok) {
sessionUser.value = null
sessionRole.value = null
sessionIsSaasAdmin.value = false
}
} catch (e) {
console.warn('[initSession] getSession falhou (tratando como sem sessão):', e)
// não deixa estourar pro router guard
sessionUser.value = null
sessionRole.value = null
sessionIsSaasAdmin.value = false
}
})()
try {
await initPromise
} finally {
initPromise = null
if (initial) sessionReady.value = true
sessionRefreshing.value = false
}
}
// refresh leve (troca de aba etc.)
export async function refreshSession () {
// ✅ evita corrida: se já está refreshing/init, não dispara outro
if (sessionRefreshing.value || initPromise) return
const { data, error } = await supabase.auth.getSession()
if (error) return
const sess = data?.session || null
const uid = sess?.user?.id || null
// se não tem sessão, não zera aqui (deixa SIGNED_OUT cuidar)
if (!uid) return
// se já está consistente, não faz nada
if (sessionUser.value?.id === uid && sessionRole.value) return
await initSession({ initial: false })
}
// evita múltiplos listeners
let authSubscription = null
export function listenAuthChanges () {
if (authSubscription) return
const { data } = supabase.auth.onAuthStateChange(async (event, sess) => {
console.log('[AUTH EVENT]', event)
// ✅ SIGNED_OUT: zera e chama callback
if (event === 'SIGNED_OUT') {
sessionUser.value = null
sessionRole.value = null
sessionIsSaasAdmin.value = false
sessionRefreshing.value = false
sessionReady.value = true
if (onSignedOutCallback) onSignedOutCallback()
return
}
// ✅ se já está consistente, ignora SIGNED_IN redundante
if (event === 'SIGNED_IN') {
const uid = sess?.user?.id || null
if (uid && sessionReady.value && sessionUser.value?.id === uid && sessionRole.value) {
return
}
}
// ✅ use a session fornecida no callback
if (sess?.user?.id) {
// evita reentrância
if (sessionRefreshing.value) return
sessionRefreshing.value = true
try {
await hydrateFromSession(sess)
sessionReady.value = true
} catch (e) {
console.warn('[auth hydrate error]', e)
} finally {
sessionRefreshing.value = false
}
return
}
// fallback: refresh leve
try {
await refreshSession()
} catch (e) {
console.error('[refreshSession error]', e)
}
})
authSubscription = data?.subscription || null
}
export function stopAuthChanges () {
if (authSubscription) {
authSubscription.unsubscribe()
authSubscription = null
}
}