From 61bdf43ddf63ea78577813909e04ae5b0ab9aef9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 20:21:55 +0000 Subject: [PATCH 1/3] perf(auth): memoize AuthContext value, fix double session refresh, harden last_login_at write - AuthContext: wrap signIn/signOut in useCallback and the context `value` in useMemo with explicit deps. The value object was rebuilt on every render, changing context identity and re-rendering every useAuth() consumer (route guards, layout, dozens of components) on any auth state change (session, profile, roles, timers, toasts). - AuthContext: collapse session auto-refresh to a single path. The old code fired an immediate refresh (timeToExpiry < 10min) AND scheduled a setTimeout whose delay could be negative (fires at ~0ms) -> redundant double refresh on near-expiry sessions. Now: refresh once if already inside the 5min buffer, otherwise schedule a single timer (null when delay <= 0). Watchdog untouched. - useProfileRoles: add .catch to the background last_login_at write so a failed update (network/RLS) cannot surface as an unhandled promise rejection. - DevRoute: align docstring with actual behavior (redirects to /auth, not /login). https://claude.ai/code/session_01Tz1z1N7dKRztHG4XH9mH3B --- src/components/layout/DevRoute.tsx | 2 +- src/contexts/AuthContext.tsx | 111 ++++++++++++++++++----------- src/hooks/auth/useProfileRoles.ts | 20 +++--- 3 files changed, 84 insertions(+), 49 deletions(-) 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 a0ce1683d..6196e67b2 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'; @@ -197,7 +198,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; @@ -210,12 +222,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]); @@ -241,7 +253,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) { @@ -277,9 +289,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { .catch(() => {}); return { error, data }; - }; + }, []); - const signOut = async () => { + const signOut = useCallback(async () => { try { await authService.signOut(); } finally { @@ -289,41 +301,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 56fa36602..baaa1c039 100644 --- a/src/hooks/auth/useProfileRoles.ts +++ b/src/hooks/auth/useProfileRoles.ts @@ -37,14 +37,18 @@ export function useProfileRoles() { if (profileResult.data) { setProfile(profileResult.data as Profile); - // background update — fire and forget, não bloqueia isLoading - getSupabaseClient().then((supabase) => - supabase - .from('profiles') - .update({ last_login_at: new Date().toISOString() }) - .eq('user_id', userId) - .then(), - ); + // background update — fire and forget, não bloqueia isLoading. + // `.catch` evita unhandled rejection se a escrita falhar (rede/RLS). + void getSupabaseClient() + .then((supabase) => + supabase + .from('profiles') + .update({ last_login_at: new Date().toISOString() }) + .eq('user_id', userId), + ) + .catch(() => { + /* atualização de last_login_at é best-effort */ + }); } if (rolesResult.data && rolesResult.data.length > 0) { From a646f589fa6e9904c8d3ce067db7637c4494239a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 20:22:28 +0000 Subject: [PATCH 2/3] chore(db): sync advisor-guided prod migrations to repo (perf + security) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Conservative, additive/idempotent migrations applied to prod (project doufsxqlfjyuvxuezpln) on 2026-05-29 via MCP, now mirrored into repo history so a fresh `db reset` reproduces prod. All guided by Supabase advisors and verified before/after: performance advisor duplicate_index count 5 -> 0, security advisors 0 ERROR, smoke tests 13/14 (the lone failure is a pre-existing cron/config issue — NULL url/apikey in pg_net keepalive — unrelated to these changes). - perf_drop_duplicate_indexes: drop 5 redundant duplicate indexes, each byte-identical to a retained sibling (verified via pg_get_indexdef); zero read-path impact, less write amplification. - perf_add_missing_fk_indexes: add covering indexes for 2 unindexed foreign keys (personalization_simulations.product_id, user_allowed_ips.created_by). - security_revoke_anon_execute_internal_secdef_fns: revoke anon/PUBLIC EXECUTE on 5 internal SECURITY DEFINER functions that have no anonymous caller; authenticated/service_role grants and all pre-auth RPCs left intact. https://claude.ai/code/session_01Tz1z1N7dKRztHG4XH9mH3B --- ...0529150000_perf_drop_duplicate_indexes.sql | 21 +++++++++++++++++ ...0529150100_perf_add_missing_fk_indexes.sql | 17 ++++++++++++++ ...evoke_anon_execute_internal_secdef_fns.sql | 23 +++++++++++++++++++ 3 files changed, 61 insertions(+) create mode 100644 supabase/migrations/20260529150000_perf_drop_duplicate_indexes.sql create mode 100644 supabase/migrations/20260529150100_perf_add_missing_fk_indexes.sql create mode 100644 supabase/migrations/20260529150200_security_revoke_anon_execute_internal_secdef_fns.sql 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..2d20e85ea --- /dev/null +++ b/supabase/migrations/20260529150200_security_revoke_anon_execute_internal_secdef_fns.sql @@ -0,0 +1,23 @@ +-- ===================================================================== +-- 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 and is idempotent (REVOKE is a no-op when the grant is already absent). +-- ===================================================================== +REVOKE EXECUTE ON FUNCTION public.fn_run_and_persist_smoke_tests() FROM anon; +REVOKE EXECUTE ON FUNCTION public.is_dnd_active(uuid) FROM PUBLIC, anon; +REVOKE EXECUTE ON FUNCTION public.send_digest_notification(uuid, uuid[], integer) FROM PUBLIC, anon; +REVOKE EXECUTE ON FUNCTION public.sync_user_org_to_org_members() FROM PUBLIC, anon; +REVOKE EXECUTE ON FUNCTION public.validate_edge_functions_base_url(text) FROM PUBLIC, anon; From 41dc1bf2adfbde19217065c9724ebef17574b1de Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 20:30:47 +0000 Subject: [PATCH 3/3] fix(db): guard secdef REVOKEs with to_regprocedure for db reset / preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Supabase preview branch and a local `db reset` build the schema from repo migration history. `send_digest_notification(uuid, uuid[], integer)` exists in prod but is created out-of-band (no repo migration), so a bare `REVOKE EXECUTE ON FUNCTION ...` for it would abort the migration with "function does not exist" on any fresh database. Wrap all 5 revokes in a single DO block guarded by `to_regprocedure(...)` (returns NULL instead of raising when the function is absent), preserving the exact per-function grantees applied to prod. Validated against prod as an idempotent no-op. No change to prod behavior — only makes the repo file reproducible on a clean DB. https://claude.ai/code/session_01Tz1z1N7dKRztHG4XH9mH3B --- ...evoke_anon_execute_internal_secdef_fns.sql | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) 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 index 2d20e85ea..245ecc9ce 100644 --- a/supabase/migrations/20260529150200_security_revoke_anon_execute_internal_secdef_fns.sql +++ b/supabase/migrations/20260529150200_security_revoke_anon_execute_internal_secdef_fns.sql @@ -14,10 +14,32 @@ -- 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 and is idempotent (REVOKE is a no-op when the grant is already absent). +-- 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. -- ===================================================================== -REVOKE EXECUTE ON FUNCTION public.fn_run_and_persist_smoke_tests() FROM anon; -REVOKE EXECUTE ON FUNCTION public.is_dnd_active(uuid) FROM PUBLIC, anon; -REVOKE EXECUTE ON FUNCTION public.send_digest_notification(uuid, uuid[], integer) FROM PUBLIC, anon; -REVOKE EXECUTE ON FUNCTION public.sync_user_org_to_org_members() FROM PUBLIC, anon; -REVOKE EXECUTE ON FUNCTION public.validate_edge_functions_base_url(text) FROM PUBLIC, anon; +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 $$;