Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/components/layout/DevRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
111 changes: 71 additions & 40 deletions src/contexts/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
useState,
useRef,
useCallback,
useMemo,
type ReactNode,
} from 'react';
import { type User, type Session, type AuthError } from '@supabase/supabase-js';
Expand Down Expand Up @@ -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;
Expand All @@ -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]);

Expand All @@ -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) {
Expand Down Expand Up @@ -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 {
Expand All @@ -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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
Expand Down
29 changes: 17 additions & 12 deletions src/hooks/auth/useProfileRoles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
21 changes: 21 additions & 0 deletions supabase/migrations/20260529150000_perf_drop_duplicate_indexes.sql
Original file line number Diff line number Diff line change
@@ -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;
17 changes: 17 additions & 0 deletions supabase/migrations/20260529150100_perf_add_missing_fk_indexes.sql
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
@@ -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 $$;
Loading