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
This commit is contained in:
2026-08-17 22:08:39 +00:00
parent 92625897bf
commit 278f0f4491
4 changed files with 782 additions and 458 deletions
+75 -386
View File
@@ -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<void>;
updatePassword: (password: string) => Promise<{ error: unknown }>;
isRecoveryFlow: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
const AuthContext = createContext<UseAuthReturn | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [session, setSession] = useState<Session | null>(null);
const [user, setUser] = useState<LogtoUser | null>(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<boolean> => {
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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
// 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 };
+428
View File
@@ -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<void>;
updatePassword: (password: string) => Promise<{ error: unknown }>;
isRecoveryFlow: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [session, setSession] = useState<Session | null>(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<boolean> => {
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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
// 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;
}
+17 -72
View File
@@ -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,
};
};
};
+262
View File
@@ -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<ArrayBuffer> {
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<void> {
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<boolean> {
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<LogtoUser | null> {
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<boolean> {
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<void> {
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);
}