feat: integrate Better Auth and enhance authentication flow

- Added Better Auth integration to the web application, allowing for dual login support with both custom and Better Auth systems.
- Updated authentication client configuration to dynamically set the base URL based on the environment.
- Enhanced chat components to utilize user authentication status, improving user experience and security.
- Refactored various components to support Better Auth, including error handling and user identity management.
- Improved notification handling and user feedback mechanisms during authentication processes.
This commit is contained in:
2025-11-06 09:35:36 -03:00
parent 33f305220b
commit 06f03b53e5
28 changed files with 4109 additions and 436 deletions

View File

@@ -27,6 +27,7 @@
"vite": "^7.1.2"
},
"dependencies": {
"@convex-dev/better-auth": "^0.9.7",
"@dicebear/collection": "^9.2.4",
"@dicebear/core": "^9.2.4",
"@fullcalendar/core": "^6.1.19",
@@ -35,13 +36,16 @@
"@fullcalendar/list": "^6.1.19",
"@fullcalendar/multimonth": "^6.1.19",
"@internationalized/date": "^3.10.0",
"@mmailaender/convex-better-auth-svelte": "^0.2.0",
"@sgse-app/backend": "*",
"@tanstack/svelte-form": "^1.19.2",
"@types/papaparse": "^5.3.14",
"better-auth": "^1.3.34",
"convex": "catalog:",
"convex-svelte": "^0.0.11",
"date-fns": "^4.1.0",
"emoji-picker-element": "^1.27.0",
"is-network-error": "^1.3.0",
"jspdf": "^3.0.3",
"jspdf-autotable": "^5.0.2",
"lucide-svelte": "^0.552.0",

View File

@@ -1,7 +1,21 @@
import { createAuthClient } from "better-auth/client";
/**
* Cliente Better Auth para frontend SvelteKit
*
* Configurado para trabalhar com Convex via plugin convexClient.
* Este cliente será usado para autenticação quando Better Auth estiver ativo.
*/
import { createAuthClient } from "better-auth/svelte";
import { convexClient } from "@convex-dev/better-auth/client/plugins";
export const authClient = createAuthClient({
baseURL: "http://localhost:5173",
plugins: [convexClient()],
// Base URL da API Better Auth (mesma do app)
baseURL: typeof window !== "undefined"
? window.location.origin // Usar origem atual em produção
: "http://localhost:5173", // Fallback para desenvolvimento
plugins: [
// Plugin Convex integra Better Auth com Convex backend
convexClient({
convexUrl: import.meta.env.PUBLIC_CONVEX_URL || "",
}),
],
});

View File

@@ -113,14 +113,29 @@
showAboutModal = false;
}
/**
* FASE 2: Login dual - tenta Better Auth primeiro, fallback para sistema customizado
*/
async function handleLogin(e: Event) {
e.preventDefault();
erroLogin = "";
carregandoLogin = true;
try {
// Usar mutation normal com WebRTC para capturar IP
// getBrowserInfo() tenta obter o IP local via WebRTC
// FASE 2: Por enquanto, sistema customizado funciona normalmente
// Quando Better Auth estiver configurado, tentaremos primeiro:
//
// try {
// await authStore.loginWithBetterAuth(matricula, senha);
// closeLoginModal();
// goto("/");
// return;
// } catch (betterAuthError) {
// // Fallback para sistema customizado
// console.log("Better Auth falhou, usando sistema customizado");
// }
// Sistema customizado (atual e funcionando)
const browserInfo = await getBrowserInfo();
const resultado = await convex.mutation(api.autenticacao.login, {

View File

@@ -18,6 +18,9 @@
import { getAvatarUrl } from "$lib/utils/avatarGenerator";
const count = useQuery(api.chat.contarNotificacoesNaoLidas, {});
// Query para verificar o ID do usuário logado (usar como referência)
const meuPerfilQuery = useQuery(api.usuarios.obterPerfil, {});
let isOpen = $state(false);
let isMinimized = $state(false);
@@ -40,12 +43,14 @@
});
// Posição do widget (arrastável)
let position = $state({ x: 0, y: 0 });
// Inicializar posição como null para indicar que precisa ser calculada
let position = $state<{ x: number; y: number } | null>(null);
let isDragging = $state(false);
let dragStart = $state({ x: 0, y: 0 });
let isAnimating = $state(false);
let dragThreshold = $state(5); // Distância mínima em pixels para considerar arrastar
let hasMoved = $state(false); // Flag para verificar se houve movimento durante o arrastar
let shouldPreventClick = $state(false); // Flag para prevenir clique após arrastar
// Tamanho da janela (redimensionável)
const MIN_WIDTH = 300;
@@ -76,7 +81,7 @@
let windowSize = $state(getSavedSize());
let isMaximized = $state(false);
let previousSize = $state({ width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT });
let previousPosition = $state({ x: 0, y: 0 });
let previousPosition = $state<{ x: number; y: number } | null>(null);
// Dimensões da janela (reativo)
let windowDimensions = $state({ width: 0, height: 0 });
@@ -97,10 +102,36 @@
updateWindowDimensions();
// Inicializar posição apenas uma vez quando as dimensões estiverem disponíveis
if (position === null) {
const saved = localStorage.getItem('chat-widget-position');
if (saved) {
try {
const parsed = JSON.parse(saved);
position = parsed;
} catch {
// Se falhar ao parsear, usar posição padrão no canto inferior direito
position = {
x: window.innerWidth - 72 - 24,
y: window.innerHeight - 72 - 24
};
}
} else {
// Posição padrão: canto inferior direito
position = {
x: window.innerWidth - 72 - 24,
y: window.innerHeight - 72 - 24
};
}
savePosition(); // Salvar posição inicial
}
const handleResize = () => {
updateWindowDimensions();
// Ajustar posição quando a janela redimensionar
ajustarPosicao();
if (position) {
ajustarPosicao();
}
};
window.addEventListener('resize', handleResize);
@@ -109,6 +140,13 @@
window.removeEventListener('resize', handleResize);
};
});
// Salvar posição no localStorage
function savePosition() {
if (typeof window !== 'undefined' && position) {
localStorage.setItem('chat-widget-position', JSON.stringify(position));
}
}
// Salvar tamanho no localStorage
function saveSize() {
@@ -138,7 +176,7 @@
}
function handleResizeMove(e: MouseEvent) {
if (!isResizing || !resizeDirection) return;
if (!isResizing || !resizeDirection || !position) return;
const deltaX = e.clientX - resizeStart.x;
const deltaY = e.clientY - resizeStart.y;
@@ -193,6 +231,63 @@
$effect(() => {
activeConversation = $conversaAtiva;
// Quando uma conversa é aberta, marcar suas mensagens como visualizadas
// para evitar notificações repetidas quando a conversa já está aberta
if (activeConversation && todasConversas?.data && authStore.usuario?._id) {
const conversas = todasConversas.data as ConversaComTimestamp[];
const conversaAberta = conversas.find((c) => String(c._id) === String(activeConversation));
if (conversaAberta && conversaAberta.ultimaMensagemTimestamp) {
const mensagemId = `${conversaAberta._id}-${conversaAberta.ultimaMensagemTimestamp}`;
if (!mensagensNotificadasGlobal.has(mensagemId)) {
mensagensNotificadasGlobal.add(mensagemId);
salvarMensagensNotificadasGlobal();
}
}
}
});
// Ajustar posição quando a janela é aberta pela primeira vez
let wasPreviouslyClosed = $state(true);
$effect(() => {
if (isOpen && !isMinimized && position && wasPreviouslyClosed) {
// Quando a janela é aberta, recalcular posição para garantir que fique visível
const winHeight = windowDimensions.height || (typeof window !== 'undefined' ? window.innerHeight : 0);
const winWidth = windowDimensions.width || (typeof window !== 'undefined' ? window.innerWidth : 0);
const widgetHeight = windowSize.height;
const widgetWidth = windowSize.width;
// Calcular limites válidos para a janela grande
const minY = -(widgetHeight - 100);
const maxY = Math.max(0, winHeight - 100);
const minX = -(widgetWidth - 100);
const maxX = Math.max(0, winWidth - 100);
// Recalcular posição Y: tentar manter próximo ao canto inferior direito mas ajustar se necessário
let newY = position.y;
// Se a posição Y estava calculada para um botão pequeno (72px), ajustar para janela grande
// Ajustar para manter aproximadamente a mesma distância do canto inferior
if (position.y > maxY || position.y < minY) {
// Se estava muito baixo (valor grande), ajustar para uma posição válida
newY = Math.max(minY, Math.min(maxY, winHeight - widgetHeight - 24));
}
// Garantir que X também está dentro dos limites
let newX = Math.max(minX, Math.min(maxX, position.x));
// Aplicar novos valores apenas se necessário
if (newX !== position.x || newY !== position.y) {
position = { x: newX, y: newY };
savePosition();
// Forçar ajuste imediatamente
ajustarPosicao();
}
wasPreviouslyClosed = false;
} else if (!isOpen || isMinimized) {
wasPreviouslyClosed = true;
}
});
// Tipos para conversas
@@ -294,15 +389,73 @@
const conversas = todasConversas.data as ConversaComTimestamp[];
// Encontrar conversas com novas mensagens
const meuId = String(authStore.usuario._id);
// Obter ID do usuário logado de forma robusta
// Prioridade: usar query do Convex (mais confiável) > authStore
const usuarioLogado = authStore.usuario;
const perfilConvex = meuPerfilQuery?.data;
// Usar ID do Convex se disponível, caso contrário usar authStore
let meuId: string | null = null;
if (perfilConvex && perfilConvex._id) {
// Usar ID retornado pela query do Convex (mais confiável)
meuId = String(perfilConvex._id).trim();
} else if (usuarioLogado && usuarioLogado._id) {
// Fallback para authStore
meuId = String(usuarioLogado._id).trim();
}
if (!meuId) {
console.warn("⚠️ [ChatWidget] Não foi possível identificar o ID do usuário logado:", {
authStore: !!usuarioLogado,
authStoreId: usuarioLogado?._id,
convexPerfil: !!perfilConvex,
convexId: perfilConvex?._id
});
return;
}
// Log para debug (apenas em desenvolvimento)
if (import.meta.env.DEV) {
console.log("🔍 [ChatWidget] Usuário logado identificado:", {
id: meuId,
fonte: perfilConvex ? "Convex Query" : "AuthStore",
nome: usuarioLogado?.nome || perfilConvex?.nome,
email: usuarioLogado?.email
});
}
conversas.forEach((conv) => {
if (!conv.ultimaMensagemTimestamp) return;
// Verificar se a última mensagem foi enviada pelo usuário atual
const remetenteIdStr = conv.ultimaMensagemRemetenteId ? String(conv.ultimaMensagemRemetenteId) : null;
// Comparação mais robusta: normalizar ambos os IDs para string e comparar
const remetenteIdStr = conv.ultimaMensagemRemetenteId
? String(conv.ultimaMensagemRemetenteId).trim()
: null;
// Log para debug da comparação (apenas em desenvolvimento)
if (import.meta.env.DEV && remetenteIdStr) {
const ehMinhaMensagem = remetenteIdStr === meuId;
if (ehMinhaMensagem) {
console.log("✅ [ChatWidget] Mensagem identificada como própria (ignorada):", {
conversaId: conv._id,
meuId,
remetenteId: remetenteIdStr,
mensagem: conv.ultimaMensagem?.substring(0, 50)
});
}
}
// Se a mensagem foi enviada pelo próprio usuário, ignorar completamente
if (remetenteIdStr && remetenteIdStr === meuId) {
// Mensagem enviada pelo próprio usuário - não tocar beep nem mostrar notificação
// Marcar como notificada para evitar processamento futuro
const mensagemId = `${conv._id}-${conv.ultimaMensagemTimestamp}`;
if (!mensagensNotificadasGlobal.has(mensagemId)) {
mensagensNotificadasGlobal.add(mensagemId);
salvarMensagensNotificadasGlobal();
}
return;
}
@@ -312,12 +465,15 @@
// Verificar se já foi notificada
if (mensagensNotificadasGlobal.has(mensagemId)) return;
const conversaAtivaId = activeConversation ? String(activeConversation) : null;
const conversaIdStr = String(conv._id);
const conversaAtivaId = activeConversation ? String(activeConversation).trim() : null;
const conversaIdStr = String(conv._id).trim();
const estaConversaEstaAberta = conversaAtivaId === conversaIdStr;
// Só mostrar notificação se não estamos vendo essa conversa
if (!isOpen || conversaAtivaId !== conversaIdStr) {
// Marcar como notificada antes de tocar som (evita duplicação)
// Só mostrar notificação se:
// 1. O chat não está aberto OU
// 2. O chat está aberto mas não estamos vendo essa conversa específica
if (!isOpen || !estaConversaEstaAberta) {
// Marcar como notificada ANTES de mostrar notificação (evita duplicação)
mensagensNotificadasGlobal.add(mensagemId);
salvarMensagensNotificadasGlobal();
@@ -340,6 +496,11 @@
showGlobalNotificationPopup = false;
globalNotificationMessage = null;
}, 5000);
} else {
// Chat está aberto e estamos vendo essa conversa - marcar como visualizada
// mas não mostrar notificação nem tocar beep
mensagensNotificadasGlobal.add(mensagemId);
salvarMensagensNotificadasGlobal();
}
});
}
@@ -362,10 +523,14 @@
}
function handleMaximize() {
if (!position) return;
if (isMaximized) {
// Restaurar tamanho anterior
windowSize = previousSize;
position = previousPosition;
if (previousPosition) {
position = previousPosition;
}
isMaximized = false;
saveSize();
ajustarPosicao();
@@ -395,27 +560,36 @@
// Funcionalidade de arrastar
function handleMouseDown(e: MouseEvent) {
if (e.button !== 0) return; // Apenas botão esquerdo
if (e.button !== 0 || !position) return; // Apenas botão esquerdo
hasMoved = false;
shouldPreventClick = false;
isDragging = true;
// Calcular offset do clique dentro do elemento (considerando a posição atual)
// Isso garante que o arrasto comece exatamente onde o usuário clicou
dragStart = {
x: e.clientX - position.x,
y: e.clientY - position.y,
};
document.body.classList.add('dragging');
e.preventDefault();
}
// Handler específico para o botão flutuante (evita conflito com clique)
function handleButtonMouseDown(e: MouseEvent) {
if (e.button !== 0) return;
// Resetar flag de movimento
if (e.button !== 0 || !position) return;
// Resetar flags de movimento e clique
hasMoved = false;
shouldPreventClick = false;
isDragging = true;
// Calcular offset do clique exatamente onde o mouse está
dragStart = {
x: e.clientX - position.x,
y: e.clientY - position.y,
};
document.body.classList.add('dragging');
// Não prevenir default para permitir clique funcionar se não houver movimento
}
@@ -426,45 +600,52 @@
return;
}
if (!isDragging) return;
if (!isDragging || !position) return;
// Calcular nova posição baseada no offset do clique
const newX = e.clientX - dragStart.x;
const newY = e.clientY - dragStart.y;
// Verificar se houve movimento significativo
// Verificar se houve movimento significativo desde o último frame
const deltaX = Math.abs(newX - position.x);
const deltaY = Math.abs(newY - position.y);
if (deltaX > dragThreshold || deltaY > dragThreshold) {
hasMoved = true;
// Se houve qualquer movimento (mesmo pequeno), marcar como movido
if (deltaX > 0 || deltaY > 0) {
// Marcar como movido se passar do threshold
if (deltaX > dragThreshold || deltaY > dragThreshold) {
hasMoved = true;
shouldPreventClick = true; // Prevenir clique se houve movimento
}
// Dimensões do widget
const widgetWidth = isOpen && !isMinimized ? windowSize.width : 72;
const widgetHeight = isOpen && !isMinimized ? windowSize.height : 72;
// Usar dimensões reativas da janela
const winWidth = windowDimensions.width || (typeof window !== 'undefined' ? window.innerWidth : 0);
const winHeight = windowDimensions.height || (typeof window !== 'undefined' ? window.innerHeight : 0);
// Limites da tela com margem de segurança
const minX = -(widgetWidth - 100); // Permitir até 100px visíveis
const maxX = Math.max(0, winWidth - 100); // Manter 100px dentro da tela
const minY = -(widgetHeight - 100);
const maxY = Math.max(0, winHeight - 100);
// Atualizar posição imediatamente - garantir suavidade
position = {
x: Math.max(minX, Math.min(newX, maxX)),
y: Math.max(minY, Math.min(newY, maxY)),
};
}
// Dimensões do widget
const widgetWidth = isOpen && !isMinimized ? windowSize.width : 72;
const widgetHeight = isOpen && !isMinimized ? windowSize.height : 72;
// Usar dimensões reativas da janela
const winWidth = windowDimensions.width || (typeof window !== 'undefined' ? window.innerWidth : 0);
const winHeight = windowDimensions.height || (typeof window !== 'undefined' ? window.innerHeight : 0);
// Limites da tela com margem de segurança
const minX = -(widgetWidth - 100); // Permitir até 100px visíveis
const maxX = Math.max(0, winWidth - 100); // Manter 100px dentro da tela
const minY = -(widgetHeight - 100);
const maxY = Math.max(0, winHeight - 100);
position = {
x: Math.max(minX, Math.min(newX, maxX)),
y: Math.max(minY, Math.min(newY, maxY)),
};
}
function handleMouseUp(e?: MouseEvent) {
const hadMoved = hasMoved;
const shouldPrevent = shouldPreventClick;
if (isDragging) {
isDragging = false;
hasMoved = false;
document.body.classList.remove('dragging');
// Se estava arrastando e houve movimento, prevenir clique
if (hadMoved && e) {
@@ -474,6 +655,17 @@
// Garantir que está dentro dos limites ao soltar
ajustarPosicao();
// Salvar posição após arrastar
savePosition();
// Aguardar um pouco antes de resetar as flags para garantir que o onclick não seja executado
setTimeout(() => {
hasMoved = false;
shouldPreventClick = false;
}, 100);
document.body.classList.remove('dragging');
}
handleResizeEnd();
@@ -481,6 +673,8 @@
}
function ajustarPosicao() {
if (!position) return;
isAnimating = true;
// Dimensões do widget
@@ -517,6 +711,9 @@
position = { x: newX, y: newY };
// Salvar posição após ajuste
savePosition();
setTimeout(() => {
isAnimating = false;
}, 300);
@@ -537,11 +734,11 @@
</script>
<!-- Botão flutuante MODERNO E ARRASTÁVEL -->
{#if !isOpen || isMinimized}
{#if (!isOpen || isMinimized) && position}
{@const winWidth = windowDimensions.width || (typeof window !== 'undefined' ? window.innerWidth : 0)}
{@const winHeight = windowDimensions.height || (typeof window !== 'undefined' ? window.innerHeight : 0)}
{@const bottomPos = position.y === 0 ? '1.5rem' : `${Math.max(0, winHeight - position.y - 72)}px`}
{@const rightPos = position.x === 0 ? '1.5rem' : `${Math.max(0, winWidth - position.x - 72)}px`}
{@const bottomPos = `${Math.max(0, winHeight - position.y - 72)}px`}
{@const rightPos = `${Math.max(0, winWidth - position.x - 72)}px`}
<button
type="button"
class="fixed group relative border-0 backdrop-blur-xl"
@@ -564,22 +761,17 @@
"
onmousedown={handleButtonMouseDown}
onmouseup={(e) => {
const hadMovedBefore = hasMoved;
handleMouseUp(e);
// Se houve movimento, prevenir o clique
if (hadMovedBefore) {
e.preventDefault();
e.stopPropagation();
}
}}
onclick={(e) => {
// Só executar toggle se não houve movimento
if (!hasMoved) {
// Só executar toggle se não houve movimento durante o arrastar
if (!shouldPreventClick && !hasMoved) {
handleToggle();
} else {
// Prevenir clique se houve movimento
e.preventDefault();
e.stopPropagation();
shouldPreventClick = false; // Resetar após prevenir
}
}}
aria-label="Abrir chat"
@@ -638,11 +830,11 @@
{/if}
<!-- Janela do Chat ULTRA MODERNA E ARRASTÁVEL -->
{#if isOpen && !isMinimized}
{#if isOpen && !isMinimized && position}
{@const winWidth = windowDimensions.width || (typeof window !== 'undefined' ? window.innerWidth : 0)}
{@const winHeight = windowDimensions.height || (typeof window !== 'undefined' ? window.innerHeight : 0)}
{@const bottomPos = position.y === 0 ? '1.5rem' : `${Math.max(0, winHeight - position.y - windowSize.height)}px`}
{@const rightPos = position.x === 0 ? '1.5rem' : `${Math.max(0, winWidth - position.x - windowSize.width)}px`}
{@const bottomPos = `${Math.max(0, winHeight - position.y - windowSize.height)}px`}
{@const rightPos = `${Math.max(0, winWidth - position.x - windowSize.width)}px`}
<div
class="fixed flex flex-col overflow-hidden backdrop-blur-2xl"
style="
@@ -801,47 +993,79 @@
<!-- Resize Handles -->
<!-- Top -->
<div
role="button"
tabindex="0"
aria-label="Redimensionar janela pela borda superior"
class="absolute top-0 left-0 right-0 h-2 cursor-ns-resize hover:bg-primary/20 transition-colors z-50"
onmousedown={(e) => handleResizeStart(e, 'n')}
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e as any, 'n')}
style="border-radius: 24px 24px 0 0;"
></div>
<!-- Bottom -->
<div
role="button"
tabindex="0"
aria-label="Redimensionar janela pela borda inferior"
class="absolute bottom-0 left-0 right-0 h-2 cursor-ns-resize hover:bg-primary/20 transition-colors z-50"
onmousedown={(e) => handleResizeStart(e, 's')}
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e as any, 's')}
style="border-radius: 0 0 24px 24px;"
></div>
<!-- Left -->
<div
role="button"
tabindex="0"
aria-label="Redimensionar janela pela borda esquerda"
class="absolute top-0 bottom-0 left-0 w-2 cursor-ew-resize hover:bg-primary/20 transition-colors z-50"
onmousedown={(e) => handleResizeStart(e, 'w')}
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e as any, 'w')}
style="border-radius: 24px 0 0 24px;"
></div>
<!-- Right -->
<div
role="button"
tabindex="0"
aria-label="Redimensionar janela pela borda direita"
class="absolute top-0 bottom-0 right-0 w-2 cursor-ew-resize hover:bg-primary/20 transition-colors z-50"
onmousedown={(e) => handleResizeStart(e, 'e')}
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e as any, 'e')}
style="border-radius: 0 24px 24px 0;"
></div>
<!-- Corners -->
<div
role="button"
tabindex="0"
aria-label="Redimensionar janela pelo canto superior esquerdo"
class="absolute top-0 left-0 w-4 h-4 cursor-nwse-resize hover:bg-primary/20 transition-colors z-50"
onmousedown={(e) => handleResizeStart(e, 'nw')}
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e as any, 'nw')}
style="border-radius: 24px 0 0 0;"
></div>
<div
role="button"
tabindex="0"
aria-label="Redimensionar janela pelo canto superior direito"
class="absolute top-0 right-0 w-4 h-4 cursor-nesw-resize hover:bg-primary/20 transition-colors z-50"
onmousedown={(e) => handleResizeStart(e, 'ne')}
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e as any, 'ne')}
style="border-radius: 0 24px 0 0;"
></div>
<div
role="button"
tabindex="0"
aria-label="Redimensionar janela pelo canto inferior esquerdo"
class="absolute bottom-0 left-0 w-4 h-4 cursor-nesw-resize hover:bg-primary/20 transition-colors z-50"
onmousedown={(e) => handleResizeStart(e, 'sw')}
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e as any, 'sw')}
style="border-radius: 0 0 0 24px;"
></div>
<div
role="button"
tabindex="0"
aria-label="Redimensionar janela pelo canto inferior direito"
class="absolute bottom-0 right-0 w-4 h-4 cursor-nwse-resize hover:bg-primary/20 transition-colors z-50"
onmousedown={(e) => handleResizeStart(e, 'se')}
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e as any, 'se')}
style="border-radius: 0 0 24px 0;"
></div>
</div>
@@ -850,18 +1074,40 @@
<!-- Popup Global de Notificação de Nova Mensagem (quando chat está fechado/minimizado) -->
{#if showGlobalNotificationPopup && globalNotificationMessage}
{@const notificationMsg = globalNotificationMessage}
<div
role="button"
tabindex="0"
aria-label="Abrir conversa: Nova mensagem de {notificationMsg.remetente}"
class="fixed top-4 right-4 z-[1000] bg-base-100 rounded-lg shadow-2xl border border-primary/20 p-4 max-w-sm cursor-pointer"
style="box-shadow: 0 10px 40px -10px rgba(0,0,0,0.3); animation: slideInRight 0.3s ease-out;"
onclick={() => {
const conversaIdToOpen = notificationMsg?.conversaId;
showGlobalNotificationPopup = false;
globalNotificationMessage = null;
if (globalNotificationTimeout) {
clearTimeout(globalNotificationTimeout);
}
// Abrir chat e conversa ao clicar
abrirChat();
abrirConversa(globalNotificationMessage.conversaId as Id<"conversas">);
if (conversaIdToOpen) {
abrirChat();
abrirConversa(conversaIdToOpen as Id<"conversas">);
}
}}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
const conversaIdToOpen = notificationMsg?.conversaId;
showGlobalNotificationPopup = false;
globalNotificationMessage = null;
if (globalNotificationTimeout) {
clearTimeout(globalNotificationTimeout);
}
if (conversaIdToOpen) {
abrirChat();
abrirConversa(conversaIdToOpen as Id<"conversas">);
}
}
}}
>
<div class="flex items-start gap-3">
@@ -878,12 +1124,13 @@
</svg>
</div>
<div class="flex-1 min-w-0">
<p class="font-semibold text-base-content text-sm mb-1">Nova mensagem de {globalNotificationMessage.remetente}</p>
<p class="text-xs text-base-content/70 line-clamp-2">{globalNotificationMessage.conteudo}</p>
<p class="font-semibold text-base-content text-sm mb-1">Nova mensagem de {notificationMsg.remetente}</p>
<p class="text-xs text-base-content/70 line-clamp-2">{notificationMsg.conteudo}</p>
<p class="text-xs text-primary mt-1">Clique para abrir</p>
</div>
<button
type="button"
aria-label="Fechar notificação"
class="flex-shrink-0 w-6 h-6 rounded-full hover:bg-base-200 flex items-center justify-center transition-colors"
onclick={(e) => {
e.stopPropagation();

View File

@@ -11,6 +11,7 @@
import SalaReuniaoManager from "./SalaReuniaoManager.svelte";
import { getAvatarUrl } from "$lib/utils/avatarGenerator";
import { authStore } from "$lib/stores/auth.svelte";
import { setupConvexAuth } from "$lib/hooks/convexAuth";
import { Bell, X, ArrowLeft, LogOut, MoreVertical, Users, Clock, XCircle } from "lucide-svelte";
interface Props {
@@ -21,13 +22,15 @@
const client = useConvexClient();
// Token é passado automaticamente via interceptadores em +layout.svelte
let showScheduleModal = $state(false);
let showSalaManager = $state(false);
let showAdminMenu = $state(false);
let showNotificacaoModal = $state(false);
const conversas = useQuery(api.chat.listarConversas, {});
const isAdmin = useQuery(api.chat.verificarSeEhAdmin, { conversaId: conversaId as any });
const isAdmin = useQuery(api.chat.verificarSeEhAdmin, { conversaId: conversaId as Id<"conversas"> });
const conversa = $derived(() => {
console.log("🔍 [ChatWindow] Buscando conversa ID:", conversaId);
@@ -91,7 +94,7 @@
try {
const resultado = await client.mutation(api.chat.sairGrupoOuSala, {
conversaId: conversaId as any,
conversaId: conversaId as Id<"conversas">,
});
if (resultado.sucesso) {
@@ -99,9 +102,10 @@
} else {
alert(resultado.erro || "Erro ao sair da conversa");
}
} catch (error: any) {
} catch (error) {
console.error("Erro ao sair da conversa:", error);
alert(error.message || "Erro ao sair da conversa");
const errorMessage = error instanceof Error ? error.message : "Erro ao sair da conversa";
alert(errorMessage);
}
}
</script>
@@ -282,7 +286,7 @@
if (!confirm("Tem certeza que deseja encerrar esta reunião? Todos os participantes serão removidos.")) return;
try {
const resultado = await client.mutation(api.chat.encerrarReuniao, {
conversaId: conversaId as any,
conversaId: conversaId as Id<"conversas">,
});
if (resultado.sucesso) {
alert("Reunião encerrada com sucesso!");
@@ -290,8 +294,9 @@
} else {
alert(resultado.erro || "Erro ao encerrar reunião");
}
} catch (error: any) {
alert(error.message || "Erro ao encerrar reunião");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Erro ao encerrar reunião";
alert(errorMessage);
}
showAdminMenu = false;
})();
@@ -384,7 +389,7 @@
try {
const resultado = await client.mutation(api.chat.enviarNotificacaoReuniao, {
conversaId: conversaId as any,
conversaId: conversaId as Id<"conversas">,
titulo: titulo.trim(),
mensagem: mensagem.trim(),
});
@@ -395,8 +400,9 @@
} else {
alert(resultado.erro || "Erro ao enviar notificação");
}
} catch (error: any) {
alert(error.message || "Erro ao enviar notificação");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Erro ao enviar notificação";
alert(errorMessage);
}
}}
>

View File

@@ -2,8 +2,11 @@
import { useConvexClient } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import { onMount } from "svelte";
import { authStore } from "$lib/stores/auth.svelte";
const client = useConvexClient();
// Token é passado automaticamente via interceptadores em +layout.svelte
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
let inactivityTimeout: ReturnType<typeof setTimeout> | null = null;

View File

@@ -0,0 +1,26 @@
/**
* Hook para garantir que o cliente Convex tenha o token configurado
*
* NOTA: O token é passado automaticamente via monkey patch no +layout.svelte
* Este hook existe apenas para compatibilidade, mas não faz nada agora.
* O token é injetado via headers nas requisições HTTP através do monkey patch.
*/
import { authStore } from "$lib/stores/auth.svelte";
/**
* Configura o token no cliente Convex
*
* IMPORTANTE: O token agora é passado automaticamente via monkey patch global.
* Este hook é mantido para compatibilidade mas não precisa ser chamado.
*
* @param client - Cliente Convex retornado por useConvexClient()
*/
export function setupConvexAuth(client: unknown) {
// Token é passado automaticamente via monkey patch em +layout.svelte
// Não precisamos fazer nada aqui, apenas manter compatibilidade
if (import.meta.env.DEV && client && authStore.token) {
console.log("✅ [setupConvexAuth] Token disponível (gerenciado via monkey patch):", authStore.token.substring(0, 20) + "...");
}
}

View File

@@ -0,0 +1,45 @@
/**
* Hook personalizado que garante autenticação no Convex
*
* Este hook substitui useConvexClient e garante que o token seja sempre passado
*
* NOTA: Este hook deve ser usado dentro de componentes Svelte com $effect
*/
import { useConvexClient } from "convex-svelte";
import { authStore } from "$lib/stores/auth.svelte";
interface ConvexClientWithAuth {
setAuth?: (token: string) => void;
clearAuth?: () => void;
}
/**
* Hook que retorna cliente Convex com autenticação configurada automaticamente
*
* IMPORTANTE: Use $effect() no componente para chamar esta função:
* ```svelte
* $effect(() => {
* useConvexWithAuth();
* });
* ```
*/
export function useConvexWithAuth() {
const client = useConvexClient();
const token = authStore.token;
const clientWithAuth = client as ConvexClientWithAuth;
// Configurar token se disponível
if (clientWithAuth && typeof clientWithAuth.setAuth === "function" && token) {
try {
clientWithAuth.setAuth(token);
if (import.meta.env.DEV) {
console.log("✅ [useConvexWithAuth] Token configurado:", token.substring(0, 20) + "...");
}
} catch (e) {
console.warn("⚠️ [useConvexWithAuth] Erro ao configurar token:", e);
}
}
return client;
}

View File

@@ -67,6 +67,10 @@ class AuthStore {
return this.state.usuario?.role.nome === "rh" || this.isAdmin;
}
/**
* FASE 2: Login dual - suporta tanto sistema customizado quanto Better Auth
* Por enquanto, mantém sistema customizado. Better Auth será adicionado depois.
*/
login(usuario: Usuario, token: string) {
this.state.usuario = usuario;
this.state.token = token;
@@ -75,8 +79,33 @@ class AuthStore {
if (browser) {
localStorage.setItem("auth_token", token);
localStorage.setItem("auth_usuario", JSON.stringify(usuario));
// FASE 2: Preparar para Better Auth (ainda não ativo)
// Quando Better Auth estiver configurado, também salvaremos sessão do Better Auth aqui
if (import.meta.env.DEV) {
console.log("✅ [AuthStore] Login realizado:", {
usuario: usuario.nome,
email: usuario.email,
sistema: "customizado" // Será "better-auth" quando migrado
});
}
}
}
/**
* FASE 2: Login via Better Auth (preparado para futuro)
* Por enquanto não implementado, será usado quando Better Auth estiver completo
*/
async loginWithBetterAuth(email: string, senha: string) {
// TODO: Implementar quando Better Auth estiver pronto
// const { authClient } = await import("$lib/auth");
// const result = await authClient.signIn.email({ email, password: senha });
// if (result.data) {
// // Obter perfil do usuário do Convex
// // this.login(usuario, result.data.session.token);
// }
throw new Error("Better Auth ainda não configurado. Use login customizado.");
}
logout() {
this.state.usuario = null;

View File

@@ -0,0 +1,64 @@
/**
* Helper para garantir que o token seja passado para todas requisições Convex
*
* Este store reativa garante que quando o token mudar no authStore,
* todos os clientes Convex sejam atualizados automaticamente.
*/
import { authStore } from "./auth.svelte";
import { browser } from "$app/environment";
import { PUBLIC_CONVEX_URL } from "$env/static/public";
let convexClients = new Set<any>();
/**
* Registrar um cliente Convex para receber atualizações de token
*/
export function registerConvexClient(client: any) {
if (!browser) return;
convexClients.add(client);
// Configurar token inicial
if (authStore.token && client.setAuth) {
client.setAuth(authStore.token);
}
// Retornar função de limpeza
return () => {
convexClients.delete(client);
};
}
/**
* Atualizar token em todos clientes registrados
*/
function updateAllClients() {
if (!browser) return;
const token = authStore.token;
convexClients.forEach((client) => {
if (client && typeof client.setAuth === "function") {
if (token) {
client.setAuth(token);
} else {
client.clearAuth?.();
}
}
});
}
// Observar mudanças no token e atualizar clientes
if (browser) {
// Usar uma abordagem reativa simples
let lastToken: string | null = null;
setInterval(() => {
const currentToken = authStore.token;
if (currentToken !== lastToken) {
lastToken = currentToken;
updateAllClients();
}
}, 500); // Verificar a cada 500ms
}

View File

@@ -1,266 +1,266 @@
/**
* Solicita permissão para notificações desktop
*/
export async function requestNotificationPermission(): Promise<NotificationPermission> {
if (!("Notification" in window)) {
console.warn("Este navegador não suporta notificações desktop");
return "denied";
}
if (Notification.permission === "granted") {
return "granted";
}
if (Notification.permission !== "denied") {
return await Notification.requestPermission();
}
return Notification.permission;
}
/**
* Mostra uma notificação desktop
*/
export function showNotification(title: string, options?: NotificationOptions): Notification | null {
if (!("Notification" in window)) {
return null;
}
if (Notification.permission !== "granted") {
return null;
}
try {
return new Notification(title, {
icon: "/favicon.png",
badge: "/favicon.png",
...options,
});
} catch (error) {
console.error("Erro ao exibir notificação:", error);
return null;
}
}
/**
* Toca o som de notificação
*/
export function playNotificationSound() {
try {
const audio = new Audio("/sounds/notification.mp3");
audio.volume = 0.5;
audio.play().catch((err) => {
console.warn("Não foi possível reproduzir o som de notificação:", err);
});
} catch (error) {
console.error("Erro ao tocar som de notificação:", error);
}
}
/**
* Verifica se o usuário está na aba ativa
*/
export function isTabActive(): boolean {
return !document.hidden;
}
/**
* Registrar service worker para push notifications
*/
export async function registrarServiceWorker(): Promise<ServiceWorkerRegistration | null> {
if (!("serviceWorker" in navigator)) {
console.warn("Service Workers não são suportados neste navegador");
return null;
}
try {
// Verificar se já existe um Service Worker ativo antes de registrar
const existingRegistration = await navigator.serviceWorker.getRegistration("/");
if (existingRegistration?.active) {
return existingRegistration;
}
// Registrar com timeout para evitar travamentos
const registerPromise = navigator.serviceWorker.register("/sw.js", {
scope: "/",
});
const timeoutPromise = new Promise<ServiceWorkerRegistration | null>((resolve) =>
setTimeout(() => resolve(null), 3000)
);
const registration = await Promise.race([registerPromise, timeoutPromise]);
if (registration) {
// Log apenas em desenvolvimento
if (import.meta.env.DEV) {
console.log("Service Worker registrado:", registration);
}
}
return registration;
} catch (error) {
// Ignorar erros silenciosamente para evitar spam no console
// especialmente erros relacionados a message channel
if (error instanceof Error) {
const errorMessage = error.message.toLowerCase();
if (
!errorMessage.includes("message channel") &&
!errorMessage.includes("registration") &&
import.meta.env.DEV
) {
console.error("Erro ao registrar Service Worker:", error);
}
}
return null;
}
}
/**
* Solicitar subscription de push notification
*/
export async function solicitarPushSubscription(): Promise<PushSubscription | null> {
try {
// Registrar service worker primeiro com timeout
const registrationPromise = registrarServiceWorker();
const timeoutPromise = new Promise<null>((resolve) =>
setTimeout(() => resolve(null), 3000)
);
const registration = await Promise.race([registrationPromise, timeoutPromise]);
if (!registration) {
return null;
}
// Verificar se push está disponível
if (!("PushManager" in window)) {
return null;
}
// Solicitar permissão com timeout
const permissionPromise = requestNotificationPermission();
const permissionTimeoutPromise = new Promise<NotificationPermission>((resolve) =>
setTimeout(() => resolve("denied"), 3000)
);
const permission = await Promise.race([permissionPromise, permissionTimeoutPromise]);
if (permission !== "granted") {
return null;
}
// Obter subscription existente ou criar nova com timeout
const getSubscriptionPromise = registration.pushManager.getSubscription();
const getSubscriptionTimeoutPromise = new Promise<PushSubscription | null>((resolve) =>
setTimeout(() => resolve(null), 3000)
);
let subscription = await Promise.race([getSubscriptionPromise, getSubscriptionTimeoutPromise]);
if (!subscription) {
// VAPID public key deve vir do backend ou config
const vapidPublicKey = import.meta.env.VITE_VAPID_PUBLIC_KEY || "";
if (!vapidPublicKey) {
// Não logar warning para evitar spam no console
return null;
}
// Converter chave para formato Uint8Array
const applicationServerKey = urlBase64ToUint8Array(vapidPublicKey);
// Subscribe com timeout
const subscribePromise = registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey,
});
const subscribeTimeoutPromise = new Promise<PushSubscription | null>((resolve) =>
setTimeout(() => resolve(null), 5000)
);
subscription = await Promise.race([subscribePromise, subscribeTimeoutPromise]);
}
return subscription;
} catch (error) {
// Ignorar erros relacionados a message channel ou service worker
if (error instanceof Error) {
const errorMessage = error.message.toLowerCase();
if (
errorMessage.includes("message channel") ||
errorMessage.includes("service worker") ||
errorMessage.includes("registration")
) {
return null;
}
}
return null;
}
}
/**
* Converter chave VAPID de base64 URL-safe para Uint8Array
*/
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
/**
* Converter PushSubscription para formato serializável
*/
export function subscriptionToJSON(subscription: PushSubscription): {
endpoint: string;
keys: { p256dh: string; auth: string };
} {
const key = subscription.getKey("p256dh");
const auth = subscription.getKey("auth");
if (!key || !auth) {
throw new Error("Chaves de subscription não encontradas");
}
return {
endpoint: subscription.endpoint,
keys: {
p256dh: arrayBufferToBase64(key),
auth: arrayBufferToBase64(auth),
},
};
}
/**
* Converter ArrayBuffer para base64
*/
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = "";
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return window.btoa(binary);
}
/**
* Remover subscription de push notification
*/
export async function removerPushSubscription(): Promise<boolean> {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await subscription.unsubscribe();
return true;
}
return false;
}
/**
* Solicita permissão para notificações desktop
*/
export async function requestNotificationPermission(): Promise<NotificationPermission> {
if (!("Notification" in window)) {
console.warn("Este navegador não suporta notificações desktop");
return "denied";
}
if (Notification.permission === "granted") {
return "granted";
}
if (Notification.permission !== "denied") {
return await Notification.requestPermission();
}
return Notification.permission;
}
/**
* Mostra uma notificação desktop
*/
export function showNotification(title: string, options?: NotificationOptions): Notification | null {
if (!("Notification" in window)) {
return null;
}
if (Notification.permission !== "granted") {
return null;
}
try {
return new Notification(title, {
icon: "/favicon.png",
badge: "/favicon.png",
...options,
});
} catch (error) {
console.error("Erro ao exibir notificação:", error);
return null;
}
}
/**
* Toca o som de notificação
*/
export function playNotificationSound() {
try {
const audio = new Audio("/sounds/notification.mp3");
audio.volume = 0.5;
audio.play().catch((err) => {
console.warn("Não foi possível reproduzir o som de notificação:", err);
});
} catch (error) {
console.error("Erro ao tocar som de notificação:", error);
}
}
/**
* Verifica se o usuário está na aba ativa
*/
export function isTabActive(): boolean {
return !document.hidden;
}
/**
* Registrar service worker para push notifications
*/
export async function registrarServiceWorker(): Promise<ServiceWorkerRegistration | null> {
if (!("serviceWorker" in navigator)) {
console.warn("Service Workers não são suportados neste navegador");
return null;
}
try {
// Verificar se já existe um Service Worker ativo antes de registrar
const existingRegistration = await navigator.serviceWorker.getRegistration("/");
if (existingRegistration?.active) {
return existingRegistration;
}
// Registrar com timeout para evitar travamentos
const registerPromise = navigator.serviceWorker.register("/sw.js", {
scope: "/",
});
const timeoutPromise = new Promise<ServiceWorkerRegistration | null>((resolve) =>
setTimeout(() => resolve(null), 3000)
);
const registration = await Promise.race([registerPromise, timeoutPromise]);
if (registration) {
// Log apenas em desenvolvimento
if (import.meta.env.DEV) {
console.log("Service Worker registrado:", registration);
}
}
return registration;
} catch (error) {
// Ignorar erros silenciosamente para evitar spam no console
// especialmente erros relacionados a message channel
if (error instanceof Error) {
const errorMessage = error.message.toLowerCase();
if (
!errorMessage.includes("message channel") &&
!errorMessage.includes("registration") &&
import.meta.env.DEV
) {
console.error("Erro ao registrar Service Worker:", error);
}
}
return null;
}
}
/**
* Solicitar subscription de push notification
*/
export async function solicitarPushSubscription(): Promise<PushSubscription | null> {
try {
// Registrar service worker primeiro com timeout
const registrationPromise = registrarServiceWorker();
const timeoutPromise = new Promise<null>((resolve) =>
setTimeout(() => resolve(null), 3000)
);
const registration = await Promise.race([registrationPromise, timeoutPromise]);
if (!registration) {
return null;
}
// Verificar se push está disponível
if (!("PushManager" in window)) {
return null;
}
// Solicitar permissão com timeout
const permissionPromise = requestNotificationPermission();
const permissionTimeoutPromise = new Promise<NotificationPermission>((resolve) =>
setTimeout(() => resolve("denied"), 3000)
);
const permission = await Promise.race([permissionPromise, permissionTimeoutPromise]);
if (permission !== "granted") {
return null;
}
// Obter subscription existente ou criar nova com timeout
const getSubscriptionPromise = registration.pushManager.getSubscription();
const getSubscriptionTimeoutPromise = new Promise<PushSubscription | null>((resolve) =>
setTimeout(() => resolve(null), 3000)
);
let subscription = await Promise.race([getSubscriptionPromise, getSubscriptionTimeoutPromise]);
if (!subscription) {
// VAPID public key deve vir do backend ou config
const vapidPublicKey = import.meta.env.VITE_VAPID_PUBLIC_KEY || "";
if (!vapidPublicKey) {
// Não logar warning para evitar spam no console
return null;
}
// Converter chave para formato Uint8Array
const applicationServerKey = urlBase64ToUint8Array(vapidPublicKey);
// Subscribe com timeout
const subscribePromise = registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey,
});
const subscribeTimeoutPromise = new Promise<PushSubscription | null>((resolve) =>
setTimeout(() => resolve(null), 5000)
);
subscription = await Promise.race([subscribePromise, subscribeTimeoutPromise]);
}
return subscription;
} catch (error) {
// Ignorar erros relacionados a message channel ou service worker
if (error instanceof Error) {
const errorMessage = error.message.toLowerCase();
if (
errorMessage.includes("message channel") ||
errorMessage.includes("service worker") ||
errorMessage.includes("registration")
) {
return null;
}
}
return null;
}
}
/**
* Converter chave VAPID de base64 URL-safe para Uint8Array
*/
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
/**
* Converter PushSubscription para formato serializável
*/
export function subscriptionToJSON(subscription: PushSubscription): {
endpoint: string;
keys: { p256dh: string; auth: string };
} {
const key = subscription.getKey("p256dh");
const auth = subscription.getKey("auth");
if (!key || !auth) {
throw new Error("Chaves de subscription não encontradas");
}
return {
endpoint: subscription.endpoint,
keys: {
p256dh: arrayBufferToBase64(key),
auth: arrayBufferToBase64(auth),
},
};
}
/**
* Converter ArrayBuffer para base64
*/
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = "";
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return window.btoa(binary);
}
/**
* Remover subscription de push notification
*/
export async function removerPushSubscription(): Promise<boolean> {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await subscription.unsubscribe();
return true;
}
return false;
}

View File

@@ -3,16 +3,115 @@
import Sidebar from "$lib/components/Sidebar.svelte";
import { PUBLIC_CONVEX_URL } from "$env/static/public";
import { setupConvex } from "convex-svelte";
// import { createSvelteAuthClient } from "@mmailaender/convex-better-auth-svelte/svelte";
// import { authClient } from "$lib/auth";
import { authStore } from "$lib/stores/auth.svelte";
import { browser } from "$app/environment";
import { createSvelteAuthClient } from "@mmailaender/convex-better-auth-svelte/svelte";
import { authClient } from "$lib/auth";
const { children } = $props();
// Configurar Convex para usar o backend local
// Interfaces TypeScript devem estar no nível superior
interface ConvexHttpClientPrototype {
_authPatched?: boolean;
mutation?: (...args: unknown[]) => Promise<unknown>;
query?: (...args: unknown[]) => Promise<unknown>;
setAuth?: (token: string) => void;
}
interface WindowWithConvexClients extends Window {
__convexClients?: Array<{ setAuth?: (token: string) => void }>;
}
// Configurar Convex
setupConvex(PUBLIC_CONVEX_URL);
// Configurar cliente de autenticação
// createSvelteAuthClient({ authClient });
// CORREÇÃO CRÍTICA: Configurar token no cliente Convex após setup
// O convex-svelte usa WebSocket, então precisamos configurar via setAuth
if (browser) {
// Aguardar setupConvex inicializar e então configurar token
$effect(() => {
const token = authStore.token;
if (!token) return;
// Aguardar um pouco para garantir que setupConvex inicializou
setTimeout(() => {
// Tentar acessar o cliente Convex interno do convex-svelte
// O convex-svelte pode usar uma instância interna, então vamos tentar várias abordagens
// Abordagem 1: Interceptar WebSocket para adicionar token como query param
const originalWebSocket = window.WebSocket;
if (!(window as { _convexWsPatched?: boolean })._convexWsPatched) {
window.WebSocket = class extends originalWebSocket {
constructor(url: string | URL, protocols?: string | string[]) {
const wsUrl = typeof url === 'string' ? url : url.href;
// Se for conexão Convex e tivermos token, adicionar como query param
if ((wsUrl.includes(PUBLIC_CONVEX_URL) || wsUrl.includes('convex.cloud')) && token) {
try {
const urlObj = new URL(wsUrl);
if (!urlObj.searchParams.has('authToken')) {
urlObj.searchParams.set('authToken', token);
super(urlObj.href, protocols);
if (import.meta.env.DEV) {
console.log("✅ [Convex Auth] Token adicionado ao WebSocket:", token.substring(0, 20) + "...");
}
return;
}
} catch (e) {
// Se falhar, usar URL original
}
}
super(url, protocols);
}
} as typeof WebSocket;
(window as { _convexWsPatched?: boolean })._convexWsPatched = true;
console.log("✅ [Convex Auth] Interceptador WebSocket configurado");
}
// Abordagem 2: Interceptar fetch para requisições HTTP (fallback)
const originalFetch = window.fetch;
if (!(window as { _convexFetchPatched?: boolean })._convexFetchPatched) {
window.fetch = function(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
const currentToken = authStore.token;
if (currentToken && (url.includes(PUBLIC_CONVEX_URL) || url.includes('convex.cloud'))) {
const headers = new Headers(init?.headers);
if (!headers.has('authorization')) {
headers.set('authorization', `Bearer ${currentToken}`);
}
return originalFetch(input, {
...init,
headers: headers,
});
}
return originalFetch(input, init);
};
(window as { _convexFetchPatched?: boolean })._convexFetchPatched = true;
console.log("✅ [Convex Auth] Interceptador Fetch configurado");
}
}, 300);
});
}
// FASE 4: Integração Better Auth com Convex
// Better Auth agora está configurado e ativo
// Usar $effect para garantir que seja executado apenas no cliente
if (browser) {
$effect(() => {
try {
createSvelteAuthClient({ authClient });
} catch (error) {
console.warn("⚠️ [Better Auth] Erro ao inicializar cliente:", error);
}
});
}
</script>
<div>

View File

@@ -1,3 +1,8 @@
import { createSvelteKitHandler } from "@mmailaender/convex-better-auth-svelte/sveltekit";
import { PUBLIC_CONVEX_URL } from "$env/static/public";
export const { GET, POST } = createSvelteKitHandler();
// PUBLIC_CONVEX_SITE_URL é necessário para o Better Auth handler
// Se não estiver definido, usar PUBLIC_CONVEX_URL como fallback
export const { GET, POST } = createSvelteKitHandler({
convexSiteUrl: PUBLIC_CONVEX_URL,
});