Explorar o código

fix: corrige vazamento de sessão entre usuários, races de auth/streaming e erros de rede não tratados

leonardo hai 1 mes
pai
achega
f0a925595f

+ 23 - 4
src/api/chat.js

@@ -1,4 +1,4 @@
-import { apiBaseUrl, apiFetch, buildAuthHeaders, refreshAccessToken, notifyAuthError } from "./client.js";
+import { apiBaseUrl, apiFetch, buildAuthHeaders, refreshAccessToken, notifyAuthError, extractErrorMessage } from "./client.js";
 
 export async function sendChat(message, { conversationId, mode } = {}) {
   return apiFetch("/api/chat", {
@@ -90,7 +90,7 @@ export async function ingestDocuments(documents) {
   });
 }
 
-export function ingestFile(file, { source, onProgress } = {}) {
+function sendIngestFile(file, { source, onProgress } = {}) {
   return new Promise((resolve, reject) => {
     const form = new FormData();
     form.append("file", file);
@@ -109,9 +109,13 @@ export function ingestFile(file, { source, onProgress } = {}) {
         } catch {
           resolve(xhr.responseText);
         }
-      } else {
-        reject(new Error(xhr.responseText || `http_error:${xhr.status}`));
+        return;
       }
+      let payload = null;
+      try { payload = JSON.parse(xhr.responseText); } catch {}
+      const err = new Error(extractErrorMessage(payload, xhr.status));
+      err.status = xhr.status;
+      reject(err);
     };
 
     xhr.onerror = () => reject(new Error("network_error"));
@@ -124,6 +128,21 @@ export function ingestFile(file, { source, onProgress } = {}) {
   });
 }
 
+export async function ingestFile(file, { source, onProgress } = {}) {
+  try {
+    return await sendIngestFile(file, { source, onProgress });
+  } catch (err) {
+    if (err.status !== 401) throw err;
+    try {
+      await refreshAccessToken();
+    } catch {
+      notifyAuthError();
+      throw new Error("session_expired");
+    }
+    return sendIngestFile(file, { source, onProgress });
+  }
+}
+
 export async function ingestUrl(url, { source } = {}) {
   return apiFetch("/api/ingest/url", {
     method: "POST",

+ 19 - 4
src/api/conversas.js

@@ -1,4 +1,4 @@
-import { apiFetch, apiBaseUrl, buildAuthHeaders } from "./client.js";
+import { apiFetch, apiBaseUrl, buildAuthHeaders, refreshAccessToken, notifyAuthError } from "./client.js";
 
 export function listarConversas() {
   return apiFetch("/api/conversas");
@@ -24,9 +24,24 @@ export function gerarSugestaoResposta(id) {
 // a rota de mídia exige Bearer token, então <img>/<audio>/<video> não podem apontar
 // direto pra ela — busca o arquivo autenticado e devolve um blob URL local
 export async function buscarMidiaBlob(conversaId, mensagemId) {
-  const res = await fetch(`${apiBaseUrl}/api/conversas/${conversaId}/mensagens/${mensagemId}/midia`, {
-    headers: buildAuthHeaders()
-  });
+  const url = `${apiBaseUrl}/api/conversas/${conversaId}/mensagens/${mensagemId}/midia`;
+  let res = await fetch(url, { headers: buildAuthHeaders() });
+
+  if (res.status === 401) {
+    try {
+      await refreshAccessToken();
+    } catch {
+      notifyAuthError();
+      throw new Error("session_expired");
+    }
+    res = await fetch(url, { headers: buildAuthHeaders() });
+  }
+
+  if (res.status === 401) {
+    notifyAuthError();
+    throw new Error("session_expired");
+  }
+
   if (!res.ok) throw new Error(`http_error:${res.status}`);
   const blob = await res.blob();
   return URL.createObjectURL(blob);

+ 10 - 0
src/composables/useAuth.js

@@ -3,6 +3,7 @@ import { loginRequest, logoutRequest, refreshTokenRequest } from "../api/auth.js
 import { setAccessToken, setRefreshCallback, setAuthErrorCallback } from "../api/client.js";
 import { resetChatSingleton } from "./useChat.js";
 import { resetConversasSingleton } from "./useConversas.js";
+import { resetUsersSingleton } from "./useUsers.js";
 
 const LOCAL_STORAGE_KEY = "auth.session";
 const SESSION_STORAGE_KEY = "auth.session.temp";
@@ -48,14 +49,19 @@ const isAuthenticated = computed(() => Boolean(user.value?.Id));
 
 let _proactiveRefreshTimer = null;
 let _refreshInFlight = null;
+let _authEpoch = 0;
 
 
 function doRefresh() {
   if (_refreshInFlight) return _refreshInFlight;
+  const epoch = _authEpoch;
   _refreshInFlight = (async () => {
     const rt = session.value?.refreshToken;
     if (!rt) throw new Error("no_refresh_token");
     const result = await refreshTokenRequest({ refreshToken: rt });
+    // logout()/authErrorCallback podem ter rodado enquanto o refresh estava em
+    // voo — não reaplicar sessão/token de um ciclo de auth que já terminou
+    if (epoch !== _authEpoch) return result.accessToken;
     session.value = {
       ...session.value,
       accessToken: result.accessToken,
@@ -98,9 +104,11 @@ function setupAuth() {
   setRefreshCallback(doRefresh);
 
   setAuthErrorCallback(() => {
+    _authEpoch++;
     clearTimeout(_proactiveRefreshTimer);
     resetChatSingleton();
     resetConversasSingleton();
+    resetUsersSingleton();
     session.value = null;
     persistSession(null);
     setAccessToken(null);
@@ -127,6 +135,7 @@ async function login({ login, senha, rememberMe = false }) {
 }
 
 async function logout() {
+  _authEpoch++;
   clearTimeout(_proactiveRefreshTimer);
   const rt = session.value?.refreshToken;
   try {
@@ -134,6 +143,7 @@ async function logout() {
   } finally {
     resetChatSingleton();
     resetConversasSingleton();
+    resetUsersSingleton();
     session.value = null;
     persistSession(null);
     setAccessToken(null);

+ 6 - 4
src/composables/useChat.js

@@ -231,10 +231,12 @@ export function useChat() {
 
     if (_streamAbort) {
       _streamAbort.abort();
-      // o abort não dispara onDone/onError (ver sendChatStream) — remove a bolha do
-      // turno interrompido antes de começar o próximo, senão ela fica presa para sempre
-      // com streaming:true e content vazio
-      messages.value = messages.value.filter((m) => !(m.role === "assistant" && m.streaming && !m.content));
+      // o abort não dispara onDone/onError (ver sendChatStream) — bolha vazia do
+      // turno interrompido é removida; bolha com conteúdo parcial é congelada
+      // (streaming:false) em vez de ficar com o cursor piscando para sempre
+      messages.value = messages.value
+        .filter((m) => !(m.role === "assistant" && m.streaming && !m.content))
+        .map((m) => (m.role === "assistant" && m.streaming ? { ...m, streaming: false } : m));
     }
     const abortController = new AbortController();
     _streamAbort = abortController;

+ 6 - 0
src/composables/useUsers.js

@@ -5,6 +5,12 @@ const users = ref([]);
 const loading = ref(false);
 const error = ref("");
 
+export function resetUsersSingleton() {
+  users.value = [];
+  loading.value = false;
+  error.value = "";
+}
+
 export function useUsers() {
   async function loadUsers() {
     loading.value = true;

+ 3 - 0
src/views/configuracoes/ConfiguracoesView.vue

@@ -42,6 +42,9 @@ const badgeConexaoTexto = computed(() => {
 async function abrirConexao() {
   mostrarModalConexao.value = true;
   await conectarWhatsapp();
+  // modal pode ter sido fechado enquanto conectarWhatsapp() estava em voo —
+  // conectar() só arma o polling depois do await, então para aqui se já fechou
+  if (!mostrarModalConexao.value) pararPollingWhatsapp();
 }
 
 function fecharConexao() {

+ 6 - 0
src/views/usuarios/UsuariosView.vue

@@ -19,6 +19,7 @@ const usuarioEmEdicao = ref(null);
 const modalSenhaAberto = ref(false);
 const usuarioRedefinicao = ref(null);
 const solicitacoes = ref([]);
+const statusEmProcessamento = ref(new Set());
 
 const solicitacaoPorUsuario = computed(() => {
   const map = new Map();
@@ -73,6 +74,8 @@ function fecharModal() {
 }
 
 async function alternarStatus(usuario) {
+  if (statusEmProcessamento.value.has(usuario.Id)) return;
+  statusEmProcessamento.value.add(usuario.Id);
   try {
     await toggleStatus(usuario.Id);
     const nome = usuario.Nome || usuario.Login || "Usuário";
@@ -80,6 +83,8 @@ async function alternarStatus(usuario) {
     toast.sucesso(`${nome} ${novoStatus} com sucesso.`);
   } catch (e) {
     toast.erro(e?.message || "Erro ao alterar status do usuário.");
+  } finally {
+    statusEmProcessamento.value.delete(usuario.Id);
   }
 }
 
@@ -359,6 +364,7 @@ onMounted(async () => {
                   <BaseButton
                     :variant="String(usuario.Status) === '1' ? 'danger' : 'secondary'"
                     size="sm"
+                    :disabled="statusEmProcessamento.has(usuario.Id)"
                     :title="String(usuario.Status) === '1' ? 'Desativar usuário' : 'Ativar usuário'"
                     :aria-label="String(usuario.Status) === '1' ? 'Desativar usuário' : 'Ativar usuário'"
                     @click="alternarStatus(usuario)"