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';
|
||||
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 };
|
||||
@@ -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 { 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,
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -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