diff --git a/src/components/layout/DevRoute.tsx b/src/components/layout/DevRoute.tsx index 2d5d0fa43..d335fb3d6 100644 --- a/src/components/layout/DevRoute.tsx +++ b/src/components/layout/DevRoute.tsx @@ -41,7 +41,7 @@ interface DevRouteProps { * com ações contextuais (solicitar acesso, copiar link, e-mail). * * Comportamento ao bloquear: - * - Não autenticado → redireciona para /login preservando `from`. + * - Não autenticado → redireciona para /auth preservando `from`. * - Autenticado sem `dev` → exibe tela de aviso com: * • CTA "Solicitar acesso a Dev" (notificação pessoal + mailto). * • CTA "Copiar link da página" para encaminhar manualmente. diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index d36dc5e32..3d1b8a5f9 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -5,6 +5,7 @@ import { useState, useRef, useCallback, + useMemo, type ReactNode, } from 'react'; import { type User, type Session, type AuthError } from '@supabase/supabase-js'; @@ -200,7 +201,18 @@ export function AuthProvider({ children }: { children: ReactNode }) { const now = Date.now(); const timeToExpiry = expiresAt - now; - if (timeToExpiry > 0 && timeToExpiry < 10 * 60 * 1000) refreshSession(); + const buffer = 5 * 60 * 1000; + const refreshDelay = timeToExpiry - buffer; + + // Expiração conhecida e já dentro da janela de buffer (≤5min): refaz uma + // única vez agora. Se `expires_at` for desconhecido (timeToExpiry≤0), NÃO + // força refresh — o autoRefreshToken do Supabase cuida disso (evita loop de + // refresh quando a sessão não traz expiry). Antes havia um refresh imediato + // (timeToExpiry < 10min) somado a um setTimeout com delay potencialmente + // negativo (dispara em 0ms) → duplo refresh redundante. + if (timeToExpiry > 0 && refreshDelay <= 0) { + refreshSession(); + } const warningTime = timeToExpiry - 2 * 60 * 1000; let warningTimer: number | null = null; @@ -213,12 +225,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, warningTime); } - const buffer = 5 * 60 * 1000; - const refreshTimer = setTimeout(() => refreshSession(), expiresAt - now - buffer); + const refreshTimer = + refreshDelay > 0 ? window.setTimeout(() => refreshSession(), refreshDelay) : null; return () => { if (warningTimer) window.clearTimeout(warningTimer); - clearTimeout(refreshTimer); + if (refreshTimer) window.clearTimeout(refreshTimer); }; }, [session, refreshSession]); @@ -244,7 +256,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { return () => window.clearTimeout(timer); }, [isLoading, setIsLoading]); - const signIn = async (email: string, password: string) => { + const signIn = useCallback(async (email: string, password: string) => { const log = createClientLogger('auth.signIn', { base: { email_domain: email.split('@')[1] } }); const { allowed, remainingSeconds } = checkLoginAllowed(email); if (!allowed) { @@ -280,9 +292,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { .catch(() => {}); return { error, data }; - }; + }, []); - const signOut = async () => { + const signOut = useCallback(async () => { try { await authService.signOut(); } finally { @@ -292,41 +304,60 @@ export function AuthProvider({ children }: { children: ReactNode }) { clearMFA(); import('@/lib/external-db-prewarm').then((m) => m.resetPrewarmSession()).catch(() => {}); } - }; + }, [clearProfileRoles, clearMFA]); const isSupervisorOrAbove = checkIsSupervisorOrAbove(userRoles); - const value: AuthContextType = { - user, - session, - profile, - isLoading, - roles: userRoles, - role: getHighestRole(userRoles), - isDev: userRoles.includes('dev'), - isSupervisor: userRoles.some((r) => ['supervisor', 'admin', 'manager'].includes(r)), - isAgente: userRoles.some((r) => ['agente', 'vendedor'].includes(r)), - isSupervisorOrAbove, - isAdmin: isSupervisorOrAbove, - isManager: userRoles.includes('manager'), - isSeller: userRoles.some((r) => ['agente', 'vendedor'].includes(r)), - canManage: isSupervisorOrAbove, - isAuthenticated: !!user, - currentAAL, - nextAAL, - hasMFA, - mfaRequired: isSupervisorOrAbove && currentAAL !== 'aal2', - rolesLoaded: userRoles.length > 0, - refreshAAL: fetchAAL, - signIn, - signOut, - refreshSession, - refreshProfile: async () => { - if (user) { - fetchPromiseRef.current = null; - await fetchUserData(user.id); - } - }, - }; + const value: AuthContextType = useMemo( + () => ({ + user, + session, + profile, + isLoading, + roles: userRoles, + role: getHighestRole(userRoles), + isDev: userRoles.includes('dev'), + isSupervisor: userRoles.some((r) => ['supervisor', 'admin', 'manager'].includes(r)), + isAgente: userRoles.some((r) => ['agente', 'vendedor'].includes(r)), + isSupervisorOrAbove, + isAdmin: isSupervisorOrAbove, + isManager: userRoles.includes('manager'), + isSeller: userRoles.some((r) => ['agente', 'vendedor'].includes(r)), + canManage: isSupervisorOrAbove, + isAuthenticated: !!user, + currentAAL, + nextAAL, + hasMFA, + mfaRequired: isSupervisorOrAbove && currentAAL !== 'aal2', + rolesLoaded: userRoles.length > 0, + refreshAAL: fetchAAL, + signIn, + signOut, + refreshSession, + refreshProfile: async () => { + if (user) { + fetchPromiseRef.current = null; + await fetchUserData(user.id); + } + }, + }), + [ + user, + session, + profile, + isLoading, + userRoles, + isSupervisorOrAbove, + currentAAL, + nextAAL, + hasMFA, + fetchAAL, + signIn, + signOut, + refreshSession, + fetchUserData, + fetchPromiseRef, + ], + ); return {children}; } diff --git a/src/hooks/auth/useProfileRoles.ts b/src/hooks/auth/useProfileRoles.ts index fbe6ec798..887736f33 100644 --- a/src/hooks/auth/useProfileRoles.ts +++ b/src/hooks/auth/useProfileRoles.ts @@ -38,18 +38,23 @@ export function useProfileRoles() { if (profileResult.data) { const profileData = profileResult.data as Profile; setProfile(profileData); - - // background update — fire and forget, com proteção de mount - getSupabaseClient().then((supabase) => { - if (!userId) return; - supabase - .from('profiles') - .update({ last_login_at: new Date().toISOString() }) - .eq('user_id', userId) - .then(({ error }) => { - if (error) authDebugError('useProfileRoles.updateLastLogin', 'failed', error); - }); - }); + + // background update — fire and forget, com proteção de mount. + // `.catch` evita unhandled rejection se conexão/escrita falhar (rede/RLS). + getSupabaseClient() + .then((supabase) => { + if (!userId) return; + return supabase + .from('profiles') + .update({ last_login_at: new Date().toISOString() }) + .eq('user_id', userId) + .then(({ error }) => { + if (error) authDebugError('useProfileRoles.updateLastLogin', 'failed', error); + }); + }) + .catch(() => { + /* atualização de last_login_at é best-effort */ + }); } if (rolesResult.data && rolesResult.data.length > 0) { diff --git a/supabase/migrations/20260529150000_perf_drop_duplicate_indexes.sql b/supabase/migrations/20260529150000_perf_drop_duplicate_indexes.sql new file mode 100644 index 000000000..685e4b40e --- /dev/null +++ b/supabase/migrations/20260529150000_perf_drop_duplicate_indexes.sql @@ -0,0 +1,21 @@ +-- ===================================================================== +-- Performance advisor: drop redundant duplicate indexes. +-- +-- Each dropped index was verified (pg_get_indexdef) to be byte-identical to a +-- retained sibling on the same table, non-unique, and backing no constraint. +-- Removing them reduces write amplification and storage with zero read-path +-- impact. Applied to prod via MCP on 2026-05-29; this file keeps the repo +-- history in sync and is idempotent (safe to re-run / db reset). +-- +-- Retained / dropped pairs: +-- integration_credentials keep idx_integration_credentials_provider drop idx_integration_creds_provider +-- product_commemorative_dates keep idx_product_commemorative_dates_category_id drop idx_product_commemorative_dates_category_id_fk +-- product_commemorative_dates keep idx_product_commemorative_dates_commemorative_date_id drop idx_product_commemorative_dates_date_id +-- quote_items keep idx_quote_items_quote_id drop idx_quote_items_quote +-- variant_supplier_sources keep idx_variant_supplier_sources_supplier_id drop idx_variant_supplier_sources_supplier_id_fk +-- ===================================================================== +DROP INDEX IF EXISTS public.idx_integration_creds_provider; +DROP INDEX IF EXISTS public.idx_product_commemorative_dates_category_id_fk; +DROP INDEX IF EXISTS public.idx_product_commemorative_dates_date_id; +DROP INDEX IF EXISTS public.idx_quote_items_quote; +DROP INDEX IF EXISTS public.idx_variant_supplier_sources_supplier_id_fk; diff --git a/supabase/migrations/20260529150100_perf_add_missing_fk_indexes.sql b/supabase/migrations/20260529150100_perf_add_missing_fk_indexes.sql new file mode 100644 index 000000000..0749a7b6a --- /dev/null +++ b/supabase/migrations/20260529150100_perf_add_missing_fk_indexes.sql @@ -0,0 +1,17 @@ +-- ===================================================================== +-- Performance advisor: add covering indexes for unindexed foreign keys. +-- +-- Both foreign keys were flagged by the Supabase performance advisor +-- (unindexed_foreign_keys). A covering index speeds up FK joins and the +-- referential-integrity checks run on parent UPDATE/DELETE. Both target +-- tables are currently empty, so the index build is instant. Applied to prod +-- via MCP on 2026-05-29; this file keeps the repo history in sync and is +-- idempotent (safe to re-run / db reset). +-- +-- personalization_simulations.product_id -> idx_personalization_simulations_product_id +-- user_allowed_ips.created_by -> idx_user_allowed_ips_created_by +-- ===================================================================== +CREATE INDEX IF NOT EXISTS idx_personalization_simulations_product_id + ON public.personalization_simulations (product_id); +CREATE INDEX IF NOT EXISTS idx_user_allowed_ips_created_by + ON public.user_allowed_ips (created_by); diff --git a/supabase/migrations/20260529150200_security_revoke_anon_execute_internal_secdef_fns.sql b/supabase/migrations/20260529150200_security_revoke_anon_execute_internal_secdef_fns.sql new file mode 100644 index 000000000..245ecc9ce --- /dev/null +++ b/supabase/migrations/20260529150200_security_revoke_anon_execute_internal_secdef_fns.sql @@ -0,0 +1,45 @@ +-- ===================================================================== +-- Security advisor hardening: revoke EXECUTE from anon/PUBLIC on internal +-- SECURITY DEFINER functions that have NO unauthenticated caller. +-- +-- SECURITY DEFINER functions execute with the *definer's* privileges; leaving +-- them callable by `anon` needlessly widens the pre-auth attack surface. Each +-- function below was verified (app code + edge functions) to have no anonymous +-- caller. Explicit `authenticated` / `service_role` grants are preserved, so +-- the admin UI (smoke tests) and service-role edge functions (send-digest, +-- send-notification) keep working. +-- +-- Intentionally NOT touched — public / pre-auth RPCs that legitimately need +-- anon EXECUTE: check_login_rate_limit, enforce_password_reset_rate_limit, +-- get_quote_token_by_value, submit_quote_response. +-- +-- Applied to prod via MCP on 2026-05-29; this file keeps the repo history in +-- sync. Each REVOKE is guarded by to_regprocedure() so a fresh `db reset` / +-- preview branch does not fail when a function is absent (e.g. +-- send_digest_notification is created out-of-band and has no repo migration +-- yet). REVOKE is itself a no-op when the grant is already absent, so the +-- whole migration is idempotent. +-- ===================================================================== +DO $$ +BEGIN + -- smoke-test runner: prod revoked from anon only (PUBLIC grant was already absent). + IF to_regprocedure('public.fn_run_and_persist_smoke_tests()') IS NOT NULL THEN + REVOKE EXECUTE ON FUNCTION public.fn_run_and_persist_smoke_tests() FROM anon; + END IF; + + IF to_regprocedure('public.is_dnd_active(uuid)') IS NOT NULL THEN + REVOKE EXECUTE ON FUNCTION public.is_dnd_active(uuid) FROM PUBLIC, anon; + END IF; + + IF to_regprocedure('public.send_digest_notification(uuid, uuid[], integer)') IS NOT NULL THEN + REVOKE EXECUTE ON FUNCTION public.send_digest_notification(uuid, uuid[], integer) FROM PUBLIC, anon; + END IF; + + IF to_regprocedure('public.sync_user_org_to_org_members()') IS NOT NULL THEN + REVOKE EXECUTE ON FUNCTION public.sync_user_org_to_org_members() FROM PUBLIC, anon; + END IF; + + IF to_regprocedure('public.validate_edge_functions_base_url(text)') IS NOT NULL THEN + REVOKE EXECUTE ON FUNCTION public.validate_edge_functions_base_url(text) FROM PUBLIC, anon; + END IF; +END $$;