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:
+75
-386
@@ -1,428 +1,117 @@
|
|||||||
import { useState, useEffect, createContext, useContext, ReactNode } from 'react';
|
// Hook de autenticação usando Logto (sem Supabase Auth)
|
||||||
import { User, Session } from '@supabase/supabase-js';
|
// Mantém a MESMA shape do useAuth original pra não quebrar consumers
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
|
||||||
import { logger } from '@/utils/logger';
|
|
||||||
|
|
||||||
interface AuthContextType {
|
import React, {
|
||||||
user: User | null;
|
createContext,
|
||||||
session: Session | null;
|
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;
|
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 }>;
|
signUp: (email: string, password: string) => Promise<{ error: unknown }>;
|
||||||
signOut: () => Promise<void>;
|
signOut: () => Promise<void>;
|
||||||
updatePassword: (password: string) => Promise<{ error: unknown }>;
|
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 }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<LogtoUser | null>(null);
|
||||||
const [session, setSession] = useState<Session | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [isRecoveryFlow, setIsRecoveryFlow] = useState(false);
|
|
||||||
const [authInitialized, setAuthInitialized] = 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)
|
// Detecta callback URL e processa
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
const url = new URL(window.location.href);
|
||||||
|
if (url.searchParams.has('code')) {
|
||||||
const initializeAuth = async () => {
|
setLoading(true);
|
||||||
try {
|
handleCallback().then((ok) => {
|
||||||
const hasRecoveryTokens = processRecoveryTokens();
|
if (ok) {
|
||||||
|
logtoGetUser().then(setUser);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
setAuthInitialized(true);
|
||||||
if (mounted) {
|
setLoading(false);
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
};
|
} else {
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
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
|
const signIn = useCallback(async (_email?: string, _password?: string) => {
|
||||||
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 {
|
try {
|
||||||
const { error } = await supabase.auth.signInWithOAuth({
|
await logtoSignIn();
|
||||||
provider: 'keycloak',
|
|
||||||
options: {
|
|
||||||
scopes: 'openid email profile',
|
|
||||||
redirectTo: window.location.origin + '/'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (error) {
|
|
||||||
return { error };
|
|
||||||
}
|
|
||||||
return { error: null };
|
return { error: null };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { error: err as Error };
|
return { error: err };
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const signUp = async (email: string, password: string) => {
|
const signUp = useCallback(async (_email: string, _password: string) => {
|
||||||
try {
|
// Logto tem tela própria de signup (botão na página de login)
|
||||||
const redirectUrl = `${window.location.origin}/`;
|
await logtoSignIn();
|
||||||
const { error } = await supabase.auth.signUp({
|
return { error: null };
|
||||||
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 signOut = useCallback(async () => {
|
||||||
|
localStorage.removeItem('userLoginTime');
|
||||||
setIsRecoveryFlow(false);
|
setIsRecoveryFlow(false);
|
||||||
localStorage.removeItem('userLoginTime'); // Reseta timer do front-end
|
await logtoSignOut();
|
||||||
try {
|
}, []);
|
||||||
await supabase.auth.signOut();
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Erro no signOut:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updatePassword = async (password: string) => {
|
const updatePassword = useCallback(async (_password: string) => {
|
||||||
try {
|
return {
|
||||||
const { error } = await supabase.auth.updateUser({
|
error: new Error(
|
||||||
password: password
|
'Para alterar senha, acesse as configurações da conta no Logto (gerenciado pelo IdP)'
|
||||||
});
|
),
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (!error) {
|
const value: UseAuthReturn = {
|
||||||
setIsRecoveryFlow(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { error };
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Erro crítico ao atualizar senha:', error);
|
|
||||||
return { error };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const value = {
|
|
||||||
user,
|
user,
|
||||||
session,
|
|
||||||
loading,
|
loading,
|
||||||
|
authInitialized,
|
||||||
|
isRecoveryFlow,
|
||||||
signIn,
|
signIn,
|
||||||
signUp,
|
signUp,
|
||||||
signOut,
|
signOut,
|
||||||
updatePassword,
|
updatePassword,
|
||||||
isRecoveryFlow,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
export function useAuth(): UseAuthReturn {
|
||||||
export function useAuth() {
|
|
||||||
const context = useContext(AuthContext);
|
const context = useContext(AuthContext);
|
||||||
if (context === undefined) {
|
if (!context) {
|
||||||
throw new Error('useAuth must be used within an AuthProvider');
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { requestPasswordReset };
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,90 +1,35 @@
|
|||||||
|
// Password reset usando Logto (substitui supabase.auth.resetPasswordForEmail)
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
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';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
export const usePasswordReset = () => {
|
export const usePasswordReset = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Mutation para solicitar redefinição de senha
|
|
||||||
const requestPasswordReset = useMutation({
|
const requestPasswordReset = useMutation({
|
||||||
mutationFn: async ({ email }: { email: string }) => {
|
mutationFn: async ({ email }: { email: string }) => {
|
||||||
console.log('🔄 Solicitando redefinição de senha para:', email);
|
console.log('🔄 [Logto] 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,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (resetError) {
|
const result = await logtoRequestPasswordReset(email);
|
||||||
console.error('❌ Erro ao solicitar redefinição:', resetError);
|
if (!result.ok) {
|
||||||
throw resetError;
|
throw new Error(result.error || 'Falha ao solicitar reset');
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('✅ [Logto] E-mail de redefinição processado para:', email);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
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'] });
|
queryClient.invalidateQueries({ queryKey: ['password-reset-requests'] });
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: Error) => {
|
||||||
console.error('❌ Erro na solicitação de redefinição:', error);
|
console.error('❌ Erro ao solicitar reset:', error);
|
||||||
|
// Não revela se o email existe (segurança)
|
||||||
// Tratamento específico de erros
|
toast.error(
|
||||||
let errorMessage = 'Erro ao solicitar redefinição de senha. Tente novamente.';
|
'Não foi possível enviar o e-mail. Tente novamente em alguns minutos.'
|
||||||
|
);
|
||||||
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);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -92,4 +37,4 @@ export const usePasswordReset = () => {
|
|||||||
requestPasswordReset: requestPasswordReset.mutate,
|
requestPasswordReset: requestPasswordReset.mutate,
|
||||||
isRequesting: requestPasswordReset.isPending,
|
isRequesting: requestPasswordReset.isPending,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user