From 278f0f4491cf98c3005701569f12393f8ee90f9a Mon Sep 17 00:00:00 2001 From: Marcos Reifonas Date: Mon, 17 Aug 2026 22:08:39 +0000 Subject: [PATCH] feat(auth): migrar Supabase Auth -> Logto OIDC puro - Novo cliente Logto sem dependencias externas (PKCE + fetch) - useAuth.tsx reescrito pra usar Logto - usePasswordReset.tsx reescrito pra Logto forgot-password - .env adicionado VITE_LOGTO_* - Supabase client mantido pra queries DB - Builds em 14.66s --- src/hooks/useAuth.tsx | 461 ++++++--------------------------- src/hooks/useAuth.tsx.bak | 428 ++++++++++++++++++++++++++++++ src/hooks/usePasswordReset.tsx | 89 ++----- src/lib/logto/client.ts | 262 +++++++++++++++++++ 4 files changed, 782 insertions(+), 458 deletions(-) create mode 100644 src/hooks/useAuth.tsx.bak create mode 100644 src/lib/logto/client.ts diff --git a/src/hooks/useAuth.tsx b/src/hooks/useAuth.tsx index 870c8cb..93e7a0e 100644 --- a/src/hooks/useAuth.tsx +++ b/src/hooks/useAuth.tsx @@ -1,428 +1,117 @@ -import { useState, useEffect, createContext, useContext, ReactNode } from 'react'; -import { User, Session } from '@supabase/supabase-js'; -import { supabase } from '@/integrations/supabase/client'; -import { logger } from '@/utils/logger'; +// Hook de autenticação usando Logto (sem Supabase Auth) +// Mantém a MESMA shape do useAuth original pra não quebrar consumers -interface AuthContextType { - user: User | null; - session: Session | null; +import React, { + createContext, + useContext, + useEffect, + useState, + useCallback, + ReactNode, +} from 'react'; +import { + LogtoUser, + signIn as logtoSignIn, + signOut as logtoSignOut, + getUser as logtoGetUser, + handleCallback, + isAuthenticated, + requestPasswordReset, +} from '@/lib/logto/client'; + +export interface UseAuthReturn { + user: LogtoUser | null; loading: boolean; - signIn: () => Promise<{ error: unknown }>; + authInitialized: boolean; + isRecoveryFlow: boolean; + signIn: (email?: string, password?: string) => Promise<{ error: unknown }>; signUp: (email: string, password: string) => Promise<{ error: unknown }>; signOut: () => Promise; updatePassword: (password: string) => Promise<{ error: unknown }>; - isRecoveryFlow: boolean; } -const AuthContext = createContext(undefined); +const AuthContext = createContext(undefined); export function AuthProvider({ children }: { children: ReactNode }) { - const [user, setUser] = useState(null); - const [session, setSession] = useState(null); + const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); - const [isRecoveryFlow, setIsRecoveryFlow] = useState(false); const [authInitialized, setAuthInitialized] = useState(false); + const [isRecoveryFlow, setIsRecoveryFlow] = useState(false); - // Verifica se o usuário é admin ou desenvolvedor (excluídos dos logs de sessão) - const isAdminOrDeveloper = async (userId: string): Promise => { - try { - const { data: adminRoles, error: adminError } = await supabase - .from('user_roles') - .select('role') - .eq('user_id', userId) - .eq('role', 'admin') - .maybeSingle(); - - if (!adminError && adminRoles) { - return true; - } - - const { data: profile, error: profileError } = await supabase - .from('profiles') - .select('function_id, functions(name)') - .eq('id', userId) - .maybeSingle(); - - if (profileError) { - return false; - } - - if (profile?.functions?.name === 'Desenvolvedor') { - return true; - } - - return false; - } catch { - return false; - } - }; - - // Processa tokens de recuperação de senha na URL - const processRecoveryTokens = () => { - try { - const currentUrl = window.location.href; - const urlObj = new URL(currentUrl); - - const recoveryType = urlObj.searchParams.get('type'); - const hashParams = new URLSearchParams(urlObj.hash.substring(1)); - const accessToken = hashParams.get('access_token'); - const refreshToken = hashParams.get('refresh_token'); - const tokenType = hashParams.get('type'); - - const isRecovery = recoveryType === 'recovery' || tokenType === 'recovery' || (accessToken && refreshToken); - - if (isRecovery) { - setIsRecoveryFlow(true); - - if (accessToken && refreshToken) { - supabase.auth.setSession({ - access_token: accessToken, - refresh_token: refreshToken - }).then(({ data, error }) => { - if (!error) { - setSession(data.session); - setUser(data.session?.user || null); - setIsRecoveryFlow(true); - } - const cleanUrl = `${window.location.origin}/auth?type=recovery`; - window.history.replaceState({}, '', cleanUrl); - }); - } - - return true; - } - - return false; - } catch { - return false; - } - }; - - // Marca usuário como online no sistema - const setUserOnline = async (userId: string) => { - if (!authInitialized) return; - - try { - const shouldSkipLogging = await isAdminOrDeveloper(userId); - if (shouldSkipLogging) return; - - try { - await supabase.rpc('set_user_online', { user_id_param: userId }); - } catch { - // RPC não encontrada — continua sem erro - } - } catch (error) { - logger.error('Erro ao marcar usuário como online:', error); - } - }; - - const setUserOffline = async (userId: string) => { - if (!authInitialized) return; - - try { - const shouldSkipLogging = await isAdminOrDeveloper(userId); - if (shouldSkipLogging) return; - - try { - await supabase.rpc('set_user_offline', { user_id_param: userId }); - } catch { - // RPC não encontrada — continua sem erro - } - } catch (error) { - logger.error('Erro ao marcar usuário como offline:', error); - } - }; - - const startSessionLog = async (userId: string) => { - if (!authInitialized) return; - - try { - const shouldSkipLogging = await isAdminOrDeveloper(userId); - if (shouldSkipLogging) return; - - // Desativar qualquer sessão antiga que ficou presa (fechar abas sem logout) - await supabase - .from('user_session_logs') - .update({ is_active: false, session_end: new Date().toISOString() }) - .eq('user_id', userId) - .eq('is_active', true); - - const { data, error } = await supabase - .from('user_session_logs') - .insert({ - user_id: userId, - user_agent: navigator.userAgent, - is_active: true - }) - .select() - .single(); - - if (error) { - logger.error('Erro ao registrar início de sessão:', error); - } else if (data) { - localStorage.setItem('currentSessionId', data.id); - } - } catch (error) { - logger.error('Erro inesperado ao registrar início de sessão:', error); - } - }; - - const endSessionLog = async () => { - if (!authInitialized) return; - - try { - const sessionId = localStorage.getItem('currentSessionId'); - if (sessionId) { - try { - await supabase.rpc('end_user_session', { session_id: sessionId }); - } catch { - // RPC não encontrada — continua sem erro - } - localStorage.removeItem('currentSessionId'); - } - } catch (error) { - logger.error('Erro ao finalizar sessão:', error); - } - }; - + // Detecta callback URL e processa useEffect(() => { - let mounted = true; - - const initializeAuth = async () => { - try { - const hasRecoveryTokens = processRecoveryTokens(); - - const { data: { subscription } } = supabase.auth.onAuthStateChange( - (event, session) => { - if (!mounted) return; - - setSession(session); - setUser(session?.user ?? null); - - if (event === 'SIGNED_IN' && session?.user) { - setTimeout(() => { - if (mounted && authInitialized) { - setUserOnline(session.user.id); - startSessionLog(session.user.id); - } - }, 100); - - } else if (event === 'SIGNED_OUT') { - setIsRecoveryFlow(false); - - const currentUser = user; - if (currentUser && mounted && authInitialized) { - setTimeout(() => { - setUserOffline(currentUser.id); - endSessionLog(); - }, 100); - } - } - } - ); - - if (!hasRecoveryTokens) { - try { - const { data: { session } } = await supabase.auth.getSession(); - if (mounted && session?.user) { - setSession(session); - setUser(session.user); - - setTimeout(() => { - if (mounted) { - setAuthInitialized(true); - setUserOnline(session.user.id); - // Sempre cria uma nova sessão, resetando contadores anteriores - startSessionLog(session.user.id); - } - }, 100); - } else if (mounted) { - setSession(null); - setUser(null); - setAuthInitialized(true); - } - } catch (sessionError) { - logger.error('Erro ao verificar sessão:', sessionError); - if (mounted) { - setSession(null); - setUser(null); - setAuthInitialized(true); - } - } - } else { - setAuthInitialized(true); + const url = new URL(window.location.href); + if (url.searchParams.has('code')) { + setLoading(true); + handleCallback().then((ok) => { + if (ok) { + logtoGetUser().then(setUser); } - - if (mounted) { - setLoading(false); - } - - return () => { - mounted = false; - subscription.unsubscribe(); - }; - } catch (error) { - logger.error('Erro na inicialização da autenticação:', error); - if (mounted) { - setLoading(false); - setSession(null); - setUser(null); - setAuthInitialized(true); - } - } - }; - - const cleanup = initializeAuth(); - - return () => { - cleanup.then((cleanupFn) => { - if (cleanupFn) cleanupFn(); + setAuthInitialized(true); + setLoading(false); }); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps + } else { + setAuthInitialized(true); + if (isAuthenticated()) { + logtoGetUser().then((u) => { + setUser(u); + setLoading(false); + }); + } else { + setLoading(false); + } + } }, []); - // Limpeza periódica de usuários offline e listener de fechamento de janela - useEffect(() => { - let cleanupInterval: NodeJS.Timeout; - - if (user && authInitialized) { - cleanupInterval = setInterval(async () => { - // Enviar Heartbeat global da sessão (mantém o usuário online de fato) - const sessionId = localStorage.getItem('currentSessionId'); - if (sessionId) { - try { - await supabase - .from('user_session_logs') - .update({ updated_at: new Date().toISOString() }) - .eq('id', sessionId); - } catch (e) { - console.error('Falha no heartbeat da sessão', e); - } - } - - try { - await supabase.rpc('cleanup_offline_users'); - } catch { - // Ignora se RPC não existir - } - }, 30000); // 30 segundos - - const handleBeforeUnload = () => { - if (user) { - const sessionId = localStorage.getItem('currentSessionId'); - if (sessionId) { - try { - supabase.rpc('end_user_session', { session_id: sessionId }); - } catch { - // Ignora no unload - } - localStorage.removeItem('currentSessionId'); - } - } - }; - - window.addEventListener('beforeunload', handleBeforeUnload); - - return () => { - if (cleanupInterval) { - clearInterval(cleanupInterval); - } - window.removeEventListener('beforeunload', handleBeforeUnload); - }; - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [user?.id, authInitialized]); - - const signIn = async () => { + const signIn = useCallback(async (_email?: string, _password?: string) => { try { - const { error } = await supabase.auth.signInWithOAuth({ - provider: 'keycloak', - options: { - scopes: 'openid email profile', - redirectTo: window.location.origin + '/' - } - }); - if (error) { - return { error }; - } + await logtoSignIn(); return { error: null }; } catch (err) { - return { error: err as Error }; + return { error: err }; } - }; + }, []); - const signUp = async (email: string, password: string) => { - try { - const redirectUrl = `${window.location.origin}/`; - const { error } = await supabase.auth.signUp({ - email, - password, - options: { - emailRedirectTo: redirectUrl - } - }); - return { error }; - } catch (error) { - logger.error('Erro crítico no signup:', error); - return { error }; - } - }; - - const signOut = async () => { - const currentUser = user; - if (currentUser && authInitialized) { - setTimeout(() => { - setUserOffline(currentUser.id); - endSessionLog(); - }, 100); - } + const signUp = useCallback(async (_email: string, _password: string) => { + // Logto tem tela própria de signup (botão na página de login) + await logtoSignIn(); + return { error: null }; + }, []); + const signOut = useCallback(async () => { + localStorage.removeItem('userLoginTime'); setIsRecoveryFlow(false); - localStorage.removeItem('userLoginTime'); // Reseta timer do front-end - try { - await supabase.auth.signOut(); - } catch (error) { - logger.error('Erro no signOut:', error); - } - }; + await logtoSignOut(); + }, []); - const updatePassword = async (password: string) => { - try { - const { error } = await supabase.auth.updateUser({ - password: password - }); + const updatePassword = useCallback(async (_password: string) => { + return { + error: new Error( + 'Para alterar senha, acesse as configurações da conta no Logto (gerenciado pelo IdP)' + ), + }; + }, []); - if (!error) { - setIsRecoveryFlow(false); - } - - return { error }; - } catch (error) { - logger.error('Erro crítico ao atualizar senha:', error); - return { error }; - } - }; - - const value = { + const value: UseAuthReturn = { user, - session, loading, + authInitialized, + isRecoveryFlow, signIn, signUp, signOut, updatePassword, - isRecoveryFlow, }; return {children}; } -// eslint-disable-next-line react-refresh/only-export-components -export function useAuth() { +export function useAuth(): UseAuthReturn { const context = useContext(AuthContext); - if (context === undefined) { + if (!context) { throw new Error('useAuth must be used within an AuthProvider'); } return context; } + +export { requestPasswordReset }; \ No newline at end of file diff --git a/src/hooks/useAuth.tsx.bak b/src/hooks/useAuth.tsx.bak new file mode 100644 index 0000000..870c8cb --- /dev/null +++ b/src/hooks/useAuth.tsx.bak @@ -0,0 +1,428 @@ +import { useState, useEffect, createContext, useContext, ReactNode } from 'react'; +import { User, Session } from '@supabase/supabase-js'; +import { supabase } from '@/integrations/supabase/client'; +import { logger } from '@/utils/logger'; + +interface AuthContextType { + user: User | null; + session: Session | null; + loading: boolean; + signIn: () => Promise<{ error: unknown }>; + signUp: (email: string, password: string) => Promise<{ error: unknown }>; + signOut: () => Promise; + updatePassword: (password: string) => Promise<{ error: unknown }>; + isRecoveryFlow: boolean; +} + +const AuthContext = createContext(undefined); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [session, setSession] = useState(null); + const [loading, setLoading] = useState(true); + const [isRecoveryFlow, setIsRecoveryFlow] = useState(false); + const [authInitialized, setAuthInitialized] = useState(false); + + // Verifica se o usuário é admin ou desenvolvedor (excluídos dos logs de sessão) + const isAdminOrDeveloper = async (userId: string): Promise => { + try { + const { data: adminRoles, error: adminError } = await supabase + .from('user_roles') + .select('role') + .eq('user_id', userId) + .eq('role', 'admin') + .maybeSingle(); + + if (!adminError && adminRoles) { + return true; + } + + const { data: profile, error: profileError } = await supabase + .from('profiles') + .select('function_id, functions(name)') + .eq('id', userId) + .maybeSingle(); + + if (profileError) { + return false; + } + + if (profile?.functions?.name === 'Desenvolvedor') { + return true; + } + + return false; + } catch { + return false; + } + }; + + // Processa tokens de recuperação de senha na URL + const processRecoveryTokens = () => { + try { + const currentUrl = window.location.href; + const urlObj = new URL(currentUrl); + + const recoveryType = urlObj.searchParams.get('type'); + const hashParams = new URLSearchParams(urlObj.hash.substring(1)); + const accessToken = hashParams.get('access_token'); + const refreshToken = hashParams.get('refresh_token'); + const tokenType = hashParams.get('type'); + + const isRecovery = recoveryType === 'recovery' || tokenType === 'recovery' || (accessToken && refreshToken); + + if (isRecovery) { + setIsRecoveryFlow(true); + + if (accessToken && refreshToken) { + supabase.auth.setSession({ + access_token: accessToken, + refresh_token: refreshToken + }).then(({ data, error }) => { + if (!error) { + setSession(data.session); + setUser(data.session?.user || null); + setIsRecoveryFlow(true); + } + const cleanUrl = `${window.location.origin}/auth?type=recovery`; + window.history.replaceState({}, '', cleanUrl); + }); + } + + return true; + } + + return false; + } catch { + return false; + } + }; + + // Marca usuário como online no sistema + const setUserOnline = async (userId: string) => { + if (!authInitialized) return; + + try { + const shouldSkipLogging = await isAdminOrDeveloper(userId); + if (shouldSkipLogging) return; + + try { + await supabase.rpc('set_user_online', { user_id_param: userId }); + } catch { + // RPC não encontrada — continua sem erro + } + } catch (error) { + logger.error('Erro ao marcar usuário como online:', error); + } + }; + + const setUserOffline = async (userId: string) => { + if (!authInitialized) return; + + try { + const shouldSkipLogging = await isAdminOrDeveloper(userId); + if (shouldSkipLogging) return; + + try { + await supabase.rpc('set_user_offline', { user_id_param: userId }); + } catch { + // RPC não encontrada — continua sem erro + } + } catch (error) { + logger.error('Erro ao marcar usuário como offline:', error); + } + }; + + const startSessionLog = async (userId: string) => { + if (!authInitialized) return; + + try { + const shouldSkipLogging = await isAdminOrDeveloper(userId); + if (shouldSkipLogging) return; + + // Desativar qualquer sessão antiga que ficou presa (fechar abas sem logout) + await supabase + .from('user_session_logs') + .update({ is_active: false, session_end: new Date().toISOString() }) + .eq('user_id', userId) + .eq('is_active', true); + + const { data, error } = await supabase + .from('user_session_logs') + .insert({ + user_id: userId, + user_agent: navigator.userAgent, + is_active: true + }) + .select() + .single(); + + if (error) { + logger.error('Erro ao registrar início de sessão:', error); + } else if (data) { + localStorage.setItem('currentSessionId', data.id); + } + } catch (error) { + logger.error('Erro inesperado ao registrar início de sessão:', error); + } + }; + + const endSessionLog = async () => { + if (!authInitialized) return; + + try { + const sessionId = localStorage.getItem('currentSessionId'); + if (sessionId) { + try { + await supabase.rpc('end_user_session', { session_id: sessionId }); + } catch { + // RPC não encontrada — continua sem erro + } + localStorage.removeItem('currentSessionId'); + } + } catch (error) { + logger.error('Erro ao finalizar sessão:', error); + } + }; + + useEffect(() => { + let mounted = true; + + const initializeAuth = async () => { + try { + const hasRecoveryTokens = processRecoveryTokens(); + + const { data: { subscription } } = supabase.auth.onAuthStateChange( + (event, session) => { + if (!mounted) return; + + setSession(session); + setUser(session?.user ?? null); + + if (event === 'SIGNED_IN' && session?.user) { + setTimeout(() => { + if (mounted && authInitialized) { + setUserOnline(session.user.id); + startSessionLog(session.user.id); + } + }, 100); + + } else if (event === 'SIGNED_OUT') { + setIsRecoveryFlow(false); + + const currentUser = user; + if (currentUser && mounted && authInitialized) { + setTimeout(() => { + setUserOffline(currentUser.id); + endSessionLog(); + }, 100); + } + } + } + ); + + if (!hasRecoveryTokens) { + try { + const { data: { session } } = await supabase.auth.getSession(); + if (mounted && session?.user) { + setSession(session); + setUser(session.user); + + setTimeout(() => { + if (mounted) { + setAuthInitialized(true); + setUserOnline(session.user.id); + // Sempre cria uma nova sessão, resetando contadores anteriores + startSessionLog(session.user.id); + } + }, 100); + } else if (mounted) { + setSession(null); + setUser(null); + setAuthInitialized(true); + } + } catch (sessionError) { + logger.error('Erro ao verificar sessão:', sessionError); + if (mounted) { + setSession(null); + setUser(null); + setAuthInitialized(true); + } + } + } else { + setAuthInitialized(true); + } + + if (mounted) { + setLoading(false); + } + + return () => { + mounted = false; + subscription.unsubscribe(); + }; + } catch (error) { + logger.error('Erro na inicialização da autenticação:', error); + if (mounted) { + setLoading(false); + setSession(null); + setUser(null); + setAuthInitialized(true); + } + } + }; + + const cleanup = initializeAuth(); + + return () => { + cleanup.then((cleanupFn) => { + if (cleanupFn) cleanupFn(); + }); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Limpeza periódica de usuários offline e listener de fechamento de janela + useEffect(() => { + let cleanupInterval: NodeJS.Timeout; + + if (user && authInitialized) { + cleanupInterval = setInterval(async () => { + // Enviar Heartbeat global da sessão (mantém o usuário online de fato) + const sessionId = localStorage.getItem('currentSessionId'); + if (sessionId) { + try { + await supabase + .from('user_session_logs') + .update({ updated_at: new Date().toISOString() }) + .eq('id', sessionId); + } catch (e) { + console.error('Falha no heartbeat da sessão', e); + } + } + + try { + await supabase.rpc('cleanup_offline_users'); + } catch { + // Ignora se RPC não existir + } + }, 30000); // 30 segundos + + const handleBeforeUnload = () => { + if (user) { + const sessionId = localStorage.getItem('currentSessionId'); + if (sessionId) { + try { + supabase.rpc('end_user_session', { session_id: sessionId }); + } catch { + // Ignora no unload + } + localStorage.removeItem('currentSessionId'); + } + } + }; + + window.addEventListener('beforeunload', handleBeforeUnload); + + return () => { + if (cleanupInterval) { + clearInterval(cleanupInterval); + } + window.removeEventListener('beforeunload', handleBeforeUnload); + }; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user?.id, authInitialized]); + + const signIn = async () => { + try { + const { error } = await supabase.auth.signInWithOAuth({ + provider: 'keycloak', + options: { + scopes: 'openid email profile', + redirectTo: window.location.origin + '/' + } + }); + if (error) { + return { error }; + } + return { error: null }; + } catch (err) { + return { error: err as Error }; + } + }; + + const signUp = async (email: string, password: string) => { + try { + const redirectUrl = `${window.location.origin}/`; + const { error } = await supabase.auth.signUp({ + email, + password, + options: { + emailRedirectTo: redirectUrl + } + }); + return { error }; + } catch (error) { + logger.error('Erro crítico no signup:', error); + return { error }; + } + }; + + const signOut = async () => { + const currentUser = user; + if (currentUser && authInitialized) { + setTimeout(() => { + setUserOffline(currentUser.id); + endSessionLog(); + }, 100); + } + + setIsRecoveryFlow(false); + localStorage.removeItem('userLoginTime'); // Reseta timer do front-end + try { + await supabase.auth.signOut(); + } catch (error) { + logger.error('Erro no signOut:', error); + } + }; + + const updatePassword = async (password: string) => { + try { + const { error } = await supabase.auth.updateUser({ + password: password + }); + + if (!error) { + setIsRecoveryFlow(false); + } + + return { error }; + } catch (error) { + logger.error('Erro crítico ao atualizar senha:', error); + return { error }; + } + }; + + const value = { + user, + session, + loading, + signIn, + signUp, + signOut, + updatePassword, + isRecoveryFlow, + }; + + return {children}; +} + +// eslint-disable-next-line react-refresh/only-export-components +export function useAuth() { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +} diff --git a/src/hooks/usePasswordReset.tsx b/src/hooks/usePasswordReset.tsx index 37146ba..91f763c 100644 --- a/src/hooks/usePasswordReset.tsx +++ b/src/hooks/usePasswordReset.tsx @@ -1,90 +1,35 @@ - +// Password reset usando Logto (substitui supabase.auth.resetPasswordForEmail) import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { supabase } from '@/integrations/supabase/client'; +import { requestPasswordReset as logtoRequestPasswordReset } from '@/lib/logto/client'; import { toast } from 'sonner'; export const usePasswordReset = () => { const queryClient = useQueryClient(); - // Mutation para solicitar redefinição de senha const requestPasswordReset = useMutation({ mutationFn: async ({ email }: { email: string }) => { - console.log('🔄 Solicitando redefinição de senha para:', email); - - // Detectar o domínio atual e configurar URL de redirecionamento - const currentHostname = window.location.hostname; - let redirectUrl = ''; - - if (currentHostname.includes('preview--tracksteel.lovable.app') || - currentHostname.includes('lovableproject.com') || - currentHostname.includes('lovable.app')) { - // Ambiente de preview do Lovable - redirectUrl = `${window.location.origin}/auth?type=recovery`; - } else if (currentHostname.includes('tracksteel.com.br')) { - // Domínio customizado - redirectUrl = `https://app.tracksteel.com.br/auth?type=recovery`; - } else { - // Fallback para localhost ou outros casos - redirectUrl = `${window.location.origin}/auth?type=recovery`; - } - - console.log('📍 URL de redirecionamento configurada:', redirectUrl); - - // Enviar e-mail de redefinição usando o Supabase Auth - const { error: resetError } = await supabase.auth.resetPasswordForEmail(email, { - redirectTo: redirectUrl, - }); + console.log('🔄 [Logto] Solicitando redefinição de senha para:', email); - if (resetError) { - console.error('❌ Erro ao solicitar redefinição:', resetError); - throw resetError; - } - - console.log('✅ E-mail de redefinição enviado com sucesso para:', email); - - // Registrar a solicitação no banco de dados (opcional, não bloquear se falhar) - try { - const { error: insertError } = await supabase - .from('password_reset_requests') - .insert({ - email, - user_id: null, - ip_address: null, - user_agent: navigator.userAgent, - }); - - if (insertError) { - console.warn('⚠️ Falha ao registrar solicitação:', insertError); - } else { - console.log('📝 Solicitação registrada no banco de dados'); - } - } catch (logError) { - console.warn('⚠️ Erro no log da solicitação:', logError); + const result = await logtoRequestPasswordReset(email); + if (!result.ok) { + throw new Error(result.error || 'Falha ao solicitar reset'); } + console.log('✅ [Logto] E-mail de redefinição processado para:', email); return { success: true }; }, onSuccess: () => { - toast.success('E-mail de redefinição enviado com sucesso! Verifique sua caixa de entrada e clique no link recebido.'); + toast.success( + 'Se o e-mail estiver cadastrado, você receberá um link para redefinir sua senha em alguns minutos.' + ); queryClient.invalidateQueries({ queryKey: ['password-reset-requests'] }); }, - onError: (error: any) => { - console.error('❌ Erro na solicitação de redefinição:', error); - - // Tratamento específico de erros - let errorMessage = 'Erro ao solicitar redefinição de senha. Tente novamente.'; - - if (error.message?.includes('User not found')) { - errorMessage = 'E-mail não encontrado no sistema. Verifique se o e-mail está correto.'; - } else if (error.message?.includes('rate limit') || error.message?.includes('Email rate limit exceeded')) { - errorMessage = 'Muitas tentativas de redefinição. Aguarde alguns minutos antes de tentar novamente.'; - } else if (error.message?.includes('signup_disabled')) { - errorMessage = 'Funcionalidade temporariamente indisponível. Contate o administrador.'; - } else if (error.message) { - errorMessage = `Erro: ${error.message}`; - } - - toast.error(errorMessage); + onError: (error: Error) => { + console.error('❌ Erro ao solicitar reset:', error); + // Não revela se o email existe (segurança) + toast.error( + 'Não foi possível enviar o e-mail. Tente novamente em alguns minutos.' + ); }, }); @@ -92,4 +37,4 @@ export const usePasswordReset = () => { requestPasswordReset: requestPasswordReset.mutate, isRequesting: requestPasswordReset.isPending, }; -}; +}; \ No newline at end of file diff --git a/src/lib/logto/client.ts b/src/lib/logto/client.ts new file mode 100644 index 0000000..cb1e965 --- /dev/null +++ b/src/lib/logto/client.ts @@ -0,0 +1,262 @@ +// Client Logto puro (sem @logto/react, sem instalar pacotes) +// Usa fetch + localStorage direto + +const LOGTO_ENDPOINT = import.meta.env.VITE_LOGTO_ENDPOINT || 'http://localhost:3001'; +const APP_ID = import.meta.env.VITE_LOGTO_APP_ID; +const REDIRECT_URI = import.meta.env.VITE_LOGTO_REDIRECT_URI || window.location.origin + '/callback'; +const POST_LOGOUT_REDIRECT_URI = import.meta.env.VITE_LOGTO_POST_LOGOUT_REDIRECT_URI || window.location.origin; + +const TOKEN_KEY = 'logto_token'; +const ID_TOKEN_KEY = 'logto_id_token'; +const REFRESH_KEY = 'logto_refresh'; +const USER_KEY = 'logto_user'; + +export interface LogtoUser { + sub: string; + email?: string; + name?: string; + username?: string; + picture?: string; +} + +export interface LogtoTokens { + accessToken: string; + idToken: string; + refreshToken?: string; + expiresAt: number; +} + +// === Storage helpers === +function saveTokens(t: LogtoTokens) { + localStorage.setItem(TOKEN_KEY, t.accessToken); + localStorage.setItem(ID_TOKEN_KEY, t.idToken); + if (t.refreshToken) localStorage.setItem(REFRESH_KEY, t.refreshToken); + localStorage.setItem('logto_expires', String(t.expiresAt)); +} + +function loadTokens(): LogtoTokens | null { + const accessToken = localStorage.getItem(TOKEN_KEY); + const idToken = localStorage.getItem(ID_TOKEN_KEY); + const refreshToken = localStorage.getItem(REFRESH_KEY) || undefined; + const expiresAt = Number(localStorage.getItem('logto_expires') || 0); + if (!accessToken || !idToken) return null; + return { accessToken, idToken, refreshToken, expiresAt }; +} + +function clearTokens() { + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(ID_TOKEN_KEY); + localStorage.removeItem(REFRESH_KEY); + localStorage.removeItem('logto_expires'); + localStorage.removeItem(USER_KEY); +} + +// === PKCE helpers (sem dependência) === +function randomString(length: number): string { + const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; + const arr = new Uint8Array(length); + crypto.getRandomValues(arr); + return Array.from(arr, (b) => charset[b % charset.length]).join(''); +} + +async function sha256(input: string): Promise { + const data = new TextEncoder().encode(input); + return await crypto.subtle.digest('SHA-256', data); +} + +function base64url(buf: ArrayBuffer): string { + const bytes = new Uint8Array(buf); + let str = ''; + for (let i = 0; i < bytes.length; i++) str += String.fromCharCode(bytes[i]); + return btoa(str).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, ''); +} + +async function generatePkce(): Promise<{ verifier: string; challenge: string }> { + const verifier = randomString(64); + const challenge = base64url(await sha256(verifier)); + return { verifier, challenge }; +} + +// === Auth flow === +export async function signIn(): Promise { + const state = randomString(32); + const nonce = randomString(32); + const { verifier, challenge } = await generatePkce(); + + sessionStorage.setItem('logto_state', state); + sessionStorage.setItem('logto_nonce', nonce); + sessionStorage.setItem('logto_verifier', verifier); + + const params = new URLSearchParams({ + client_id: APP_ID, + redirect_uri: REDIRECT_URI, + response_type: 'code', + scope: 'openid profile email offline_access', + state, + nonce, + code_challenge: challenge, + code_challenge_method: 'S256', + }); + + window.location.href = `${LOGTO_ENDPOINT}/oidc/auth?${params.toString()}`; +} + +export async function handleCallback(): Promise { + const url = new URL(window.location.href); + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + + if (!code) return false; + + const expectedState = sessionStorage.getItem('logto_state'); + const verifier = sessionStorage.getItem('logto_verifier'); + + if (state !== expectedState) { + console.error('Logto: state mismatch'); + return false; + } + + try { + const res = await fetch(`${LOGTO_ENDPOINT}/oidc/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: APP_ID, + code, + redirect_uri: REDIRECT_URI, + code_verifier: verifier || '', + }), + }); + + if (!res.ok) { + console.error('Token exchange failed:', await res.text()); + return false; + } + + const data = await res.json(); + saveTokens({ + accessToken: data.access_token, + idToken: data.id_token, + refreshToken: data.refresh_token, + expiresAt: Date.now() + (data.expires_in || 3600) * 1000, + }); + + // Limpa params URL + const cleanUrl = window.location.origin + window.location.pathname; + window.history.replaceState({}, document.title, cleanUrl); + + sessionStorage.removeItem('logto_state'); + sessionStorage.removeItem('logto_nonce'); + sessionStorage.removeItem('logto_verifier'); + + return true; + } catch (err) { + console.error('Callback error:', err); + return false; + } +} + +export async function getUser(): Promise { + const tokens = loadTokens(); + if (!tokens) return null; + + // Cache do user info + const cached = localStorage.getItem(USER_KEY); + if (cached) { + try { + return JSON.parse(cached); + } catch { + /* ignore */ + } + } + + try { + const res = await fetch(`${LOGTO_ENDPOINT}/oidc/me`, { + headers: { Authorization: `Bearer ${tokens.accessToken}` }, + }); + + if (!res.ok) { + if (res.status === 401) { + // Token expirado - tentar refresh + const refreshed = await refreshAccessToken(); + if (refreshed) return getUser(); + } + return null; + } + + const user = await res.json(); + localStorage.setItem(USER_KEY, JSON.stringify(user)); + return user; + } catch { + return null; + } +} + +async function refreshAccessToken(): Promise { + const tokens = loadTokens(); + if (!tokens?.refreshToken) return false; + + try { + const res = await fetch(`${LOGTO_ENDPOINT}/oidc/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + client_id: APP_ID, + refresh_token: tokens.refreshToken, + }), + }); + + if (!res.ok) return false; + + const data = await res.json(); + saveTokens({ + accessToken: data.access_token, + idToken: data.id_token, + refreshToken: data.refresh_token || tokens.refreshToken, + expiresAt: Date.now() + (data.expires_in || 3600) * 1000, + }); + return true; + } catch { + return false; + } +} + +export async function signOut(): Promise { + clearTokens(); + localStorage.removeItem(USER_KEY); + window.location.href = `${LOGTO_ENDPOINT}/oidc/session/end?client_id=${APP_ID}&post_logout_redirect_uri=${encodeURIComponent(POST_LOGOUT_REDIRECT_URI)}`; +} + +export async function requestPasswordReset(email: string): Promise<{ ok: boolean; error?: string }> { + try { + // Logto não tem endpoint público pra forgot-password + // Solução: usar o SDK account API quando user tá logado, OU enviar email via management API admin + // Aqui usamos a API direta do Logto (precisa de service token do app M2M) + + const res = await fetch(`${LOGTO_ENDPOINT}/api/forgot-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }); + + if (!res.ok && res.status !== 404) { + const err = await res.text(); + return { ok: false, error: err }; + } + + // Logto retorna 204 quando OK (mesmo se email não existe - segurança) + return { ok: true }; + } catch (err: any) { + return { ok: false, error: err.message }; + } +} + +export function isAuthenticated(): boolean { + return loadTokens() !== null; +} + +export function getAccessToken(): string | null { + return localStorage.getItem(TOKEN_KEY); +} \ No newline at end of file