浏览代码

ajustes pagina de login

leonardo 2 月之前
父节点
当前提交
a9b4aae4d0

+ 2 - 2
dist/index.html

@@ -6,8 +6,8 @@
     <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
     <link rel="icon" type="image/x-icon" href="/favicon.ico?v=2" />
     <title>ORÁCULO</title>
-    <script type="module" crossorigin src="/assets/index-D3zvInNN.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-DPYpbLP7.css">
+    <script type="module" crossorigin src="/assets/index-ChzCvQuO.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-DJ5kheLn.css">
   </head>
   <body class="bg-background">
     <noscript>

+ 20 - 13
src/api/auth.js

@@ -1,4 +1,4 @@
-import { apiBaseUrl, extractErrorMessage } from "./client.js";
+import { apiBaseUrl, extractErrorMessage, ApiError, categorizeHttpStatus } from "./client.js";
 
 async function parseResponse(res) {
   const contentType = res.headers.get("content-type") ?? "";
@@ -11,22 +11,29 @@ async function parseResponse(res) {
 }
 
 export async function loginRequest({ login, senha, rememberMe = false }) {
-  const res = await fetch(`${apiBaseUrl}/api/auth/login`, {
-    method: "POST",
-    headers: {
-      "content-type": "application/json"
-    },
-    body: JSON.stringify({
-      login,
-      senha,
-      rememberMe
-    })
-  });
+  let res;
+  try {
+    res = await fetch(`${apiBaseUrl}/api/auth/login`, {
+      method: "POST",
+      headers: {
+        "content-type": "application/json"
+      },
+      body: JSON.stringify({
+        login,
+        senha,
+        rememberMe
+      })
+    });
+  } catch {
+    throw new ApiError("network", "network_error");
+  }
 
   const payload = await parseResponse(res);
 
   if (!res.ok || payload?.status === false) {
-    throw new Error(extractErrorMessage(payload, res.status));
+    throw new ApiError(categorizeHttpStatus(res.status), extractErrorMessage(payload, res.status), {
+      retryAfterSeconds: Number(payload?.retryAfterSeconds) || 0
+    });
   }
 
   return payload;

+ 15 - 0
src/api/client.js

@@ -7,6 +7,21 @@ export function extractErrorMessage(payload, status) {
   return `http_error:${status}`;
 }
 
+
+export class ApiError extends Error {
+  constructor(category, message, extra = {}) {
+    super(message || category);
+    this.category = category;
+    Object.assign(this, extra);
+  }
+}
+
+export function categorizeHttpStatus(status) {
+  if (status === 401) return "invalid_credentials";
+  if (status === 429) return "locked";
+  return "server";
+}
+
 let _accessToken = null;
 let _refreshFn = null;
 let _authErrorFn = null;

+ 13 - 2
src/components/ChatWindow.vue

@@ -71,15 +71,15 @@
               class="mt-1.5 flex flex-wrap gap-1"
             >
               <span
-                v-for="(src, si) in m.sources"
+                v-for="(src, si) in agruparFontes(m.sources)"
                 :key="si"
+                :title="src.count > 1 ? `${src.count} trechos deste documento` : undefined"
                 class="inline-flex items-center gap-1 rounded-md border border-gray-200 bg-gray-50 px-2 py-0.5 text-[10px] text-gray-500 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-400"
               >
                 <svg class="h-2.5 w-2.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                   <path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5.586a1 1 0 0 1 .707.293l5.414 5.414a1 1 0 0 1 .293.707V19a2 2 0 0 1-2 2z" />
                 </svg>
                 {{ src.source }}
-                <span class="opacity-60">{{ Math.round((src.score ?? 0) * 100) }}%</span>
               </span>
             </div>
           </div>
@@ -190,6 +190,17 @@ marked.use({
   gfm: true,
 });
 
+function agruparFontes(sources) {
+  const porDocumento = new Map();
+  for (const s of sources ?? []) {
+    const chave = s.source ?? "";
+    const grupo = porDocumento.get(chave);
+    if (grupo) grupo.count += 1;
+    else porDocumento.set(chave, { ...s, count: 1 });
+  }
+  return [...porDocumento.values()];
+}
+
 function renderMarkdown(content) {
   if (!content) return "";
   const html = marked.parse(content);

+ 28 - 23
src/composables/useAuth.js

@@ -46,6 +46,29 @@ const user = computed(() => session.value?.usuario ?? null);
 const isAuthenticated = computed(() => Boolean(user.value?.Id));
 
 let _proactiveRefreshTimer = null;
+let _refreshInFlight = null;
+
+
+function doRefresh() {
+  if (_refreshInFlight) return _refreshInFlight;
+  _refreshInFlight = (async () => {
+    const rt = session.value?.refreshToken;
+    if (!rt) throw new Error("no_refresh_token");
+    const result = await refreshTokenRequest({ refreshToken: rt });
+    session.value = {
+      ...session.value,
+      accessToken: result.accessToken,
+      refreshToken: result.refreshToken ?? session.value?.refreshToken ?? null
+    };
+    persistSession(session.value);
+    setAccessToken(result.accessToken);
+    scheduleProactiveRefresh(result.accessToken);
+    return result.accessToken;
+  })().finally(() => {
+    _refreshInFlight = null;
+  });
+  return _refreshInFlight;
+}
 
 function scheduleProactiveRefresh(token) {
   clearTimeout(_proactiveRefreshTimer);
@@ -54,21 +77,12 @@ function scheduleProactiveRefresh(token) {
     const payload = JSON.parse(atob(token.split(".")[1]));
     const delay = payload.exp * 1000 - Date.now() - 60_000;
     if (delay <= 0) return;
-    _proactiveRefreshTimer = setTimeout(async () => {
-      const rt = session.value?.refreshToken;
-      if (!rt) return;
-      try {
-        const result = await refreshTokenRequest({ refreshToken: rt });
-        session.value = { ...session.value, accessToken: result.accessToken };
-        persistSession(session.value);
-        setAccessToken(result.accessToken);
-        scheduleProactiveRefresh(result.accessToken);
-      } catch {
-        // 401 interceptor handles expired token on next request
-      }
+    _proactiveRefreshTimer = setTimeout(() => {
+      doRefresh().catch(() => {
+      });
     }, delay);
   } catch {
-    // token não é JWT válido, ignora
+
   }
 }
 
@@ -80,16 +94,7 @@ function setupAuth() {
 
   scheduleProactiveRefresh(token);
 
-  setRefreshCallback(async () => {
-    const rt = session.value?.refreshToken;
-    if (!rt) throw new Error("no_refresh_token");
-    const result = await refreshTokenRequest({ refreshToken: rt });
-    session.value = { ...session.value, accessToken: result.accessToken };
-    persistSession(session.value);
-    setAccessToken(result.accessToken);
-    scheduleProactiveRefresh(result.accessToken);
-    return result.accessToken;
-  });
+  setRefreshCallback(doRefresh);
 
   setAuthErrorCallback(() => {
     clearTimeout(_proactiveRefreshTimer);

+ 134 - 17
src/views/auth/LoginView.vue

@@ -1,5 +1,5 @@
 <script setup>
-import { computed, onMounted, ref, watch } from "vue";
+import { computed, onMounted, onUnmounted, ref, watch } from "vue";
 import { useRoute, useRouter } from "vue-router";
 import BotaoAlterarTema from "../../components/BotaoAlterarTema.vue";
 import { useAuth } from "../../composables/useAuth.js";
@@ -14,27 +14,78 @@ const form = ref({
   rememberMe: false
 });
 const loading = ref(false);
-const error = ref("");
+const errorCategory = ref("");
+const retryAfter = ref(0);
 const senhaVisivel = ref(false);
+const capsLockOn = ref(false);
 const loginInputRef = ref(null);
 
+let countdownTimer = null;
+
+const ERROR_MESSAGES = {
+  invalid_credentials: "Usuário ou senha incorretos. Confira os dados e tente novamente.",
+  network: "Não foi possível conectar ao servidor. Verifique sua conexão e tente novamente.",
+  server: "O servidor está indisponível no momento. Tente novamente em instantes."
+};
+
 onMounted(() => {
   loginInputRef.value?.focus();
 });
 
+onUnmounted(() => {
+  clearInterval(countdownTimer);
+});
+
+const bloqueado = computed(() => retryAfter.value > 0);
+
+const tempoRestante = computed(() => {
+  const min = Math.floor(retryAfter.value / 60);
+  const seg = retryAfter.value % 60;
+  return `${String(min).padStart(2, "0")}:${String(seg).padStart(2, "0")}`;
+});
+
+const errorMessage = computed(() => {
+  if (!errorCategory.value) return "";
+  if (errorCategory.value === "locked") {
+    return bloqueado.value
+      ? `Muitas tentativas incorretas. Tente novamente em ${tempoRestante.value}.`
+      : "Muitas tentativas incorretas. Aguarde alguns minutos e tente novamente.";
+  }
+  return ERROR_MESSAGES[errorCategory.value] ?? ERROR_MESSAGES.server;
+});
+
 const canSubmit = computed(() => {
-  return Boolean(form.value.login.trim() && form.value.senha);
+  return Boolean(form.value.login.trim() && form.value.senha) && !bloqueado.value;
 });
 
 watch(() => [form.value.login, form.value.senha], () => {
-  if (error.value) error.value = "";
+  
+  if (errorCategory.value && errorCategory.value !== "locked") errorCategory.value = "";
 });
 
+function startCountdown(seconds) {
+  clearInterval(countdownTimer);
+  retryAfter.value = seconds;
+  countdownTimer = setInterval(() => {
+    retryAfter.value -= 1;
+    if (retryAfter.value <= 0) {
+      clearInterval(countdownTimer);
+      countdownTimer = null;
+      retryAfter.value = 0;
+      errorCategory.value = "";
+    }
+  }, 1000);
+}
+
+function onSenhaKeyEvent(event) {
+  capsLockOn.value = event.getModifierState?.("CapsLock") ?? false;
+}
+
 async function onSubmit() {
   if (!canSubmit.value || loading.value) return;
 
   loading.value = true;
-  error.value = "";
+  errorCategory.value = "";
 
   try {
     await login(form.value);
@@ -42,7 +93,10 @@ async function onSubmit() {
     const redirect = raw.startsWith("/") && !raw.startsWith("//") ? raw : "/";
     await router.replace(redirect);
   } catch (err) {
-    error.value = err?.message || "Falha ao realizar login.";
+    errorCategory.value = err?.category ?? "server";
+    if (errorCategory.value === "locked" && Number(err?.retryAfterSeconds) > 0) {
+      startCountdown(Number(err.retryAfterSeconds));
+    }
   } finally {
     loading.value = false;
   }
@@ -75,9 +129,23 @@ async function onSubmit() {
       <div
         class="w-full max-w-md rounded-[28px] border border-gray-300 bg-white/80 p-6 shadow-sm backdrop-blur-md dark:border-white/[0.18] dark:bg-gradient-to-b dark:from-[#101827] dark:to-[#070b16] dark:shadow-[0_18px_50px_rgba(0,0,0,0.35)]"
       >
-        <div class="text-2xl font-extrabold tracking-tight">Entrar</div>
-        <div class="mt-2 text-sm text-gray-500 dark:text-gray-400">
-          Informe seu usuario ou email e sua senha para acessar o sistema.
+        <div class="flex items-center gap-3">
+          <div
+            aria-hidden="true"
+            class="flex h-11 w-11 items-center justify-center rounded-2xl bg-primary/10 text-primary dark:bg-primary/20"
+          >
+            <svg class="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
+              <circle cx="12" cy="12" r="8.25" />
+              <circle cx="12" cy="12" r="3" fill="currentColor" stroke="none" />
+              <path stroke-linecap="round" d="M12 1.75v2.5M12 19.75v2.5M1.75 12h2.5M19.75 12h2.5" />
+            </svg>
+          </div>
+          <div>
+            <div class="text-2xl font-extrabold tracking-tight">Entrar</div>
+            <div class="text-sm text-gray-500 dark:text-gray-400">
+              Informe seu usuario ou email e sua senha para acessar o sistema.
+            </div>
+          </div>
         </div>
 
         <form class="mt-6 grid gap-4" @submit.prevent="onSubmit">
@@ -92,6 +160,8 @@ async function onSubmit() {
               placeholder="Digite seu login"
               required
               aria-required="true"
+              :aria-invalid="errorMessage ? 'true' : undefined"
+              :aria-describedby="errorMessage ? 'login-error' : undefined"
             />
           </label>
 
@@ -106,7 +176,12 @@ async function onSubmit() {
                 placeholder="Digite sua senha"
                 required
                 aria-required="true"
+                :aria-invalid="errorMessage ? 'true' : undefined"
+                :aria-describedby="errorMessage ? 'login-error' : undefined"
                 class="pr-10"
+                @keydown="onSenhaKeyEvent"
+                @keyup="onSenhaKeyEvent"
+                @blur="capsLockOn = false"
               />
               <button
                 type="button"
@@ -123,6 +198,13 @@ async function onSubmit() {
                 </svg>
               </button>
             </div>
+            <p
+              v-if="capsLockOn"
+              role="status"
+              class="text-xs font-medium text-amber-600 dark:text-amber-400"
+            >
+              Caps Lock está ativado
+            </p>
           </label>
 
           <label class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
@@ -134,19 +216,34 @@ async function onSubmit() {
             <span>Manter conectado neste navegador</span>
           </label>
 
-          <div
-            v-if="error"
-            role="alert"
-            class="rounded-xl border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-500/40 dark:bg-red-500/10 dark:text-red-200"
-          >
-            {{ error }}
-          </div>
+          <Transition name="error-banner">
+            <div
+              v-if="errorMessage"
+              id="login-error"
+              role="alert"
+              class="rounded-xl border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-500/40 dark:bg-red-500/10 dark:text-red-200"
+            >
+              {{ errorMessage }}
+            </div>
+          </Transition>
 
           <button
             type="submit"
-            class="mt-2 inline-flex items-center justify-center rounded-xl border border-primary bg-primary px-4 py-2.5 text-sm font-semibold text-primary-foreground transition-colors hover:border-[var(--primary-600)] hover:bg-[var(--primary-600)] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
+            class="mt-2 inline-flex items-center justify-center gap-2 rounded-xl border border-primary bg-primary px-4 py-2.5 text-sm font-semibold text-primary-foreground transition-colors hover:border-[var(--primary-600)] hover:bg-[var(--primary-600)] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
             :disabled="!canSubmit || loading"
           >
+            <svg
+              v-if="loading"
+              aria-hidden="true"
+              class="h-4 w-4 motion-safe:animate-spin"
+              viewBox="0 0 24 24"
+              fill="none"
+              stroke="currentColor"
+              stroke-width="3"
+            >
+              <circle cx="12" cy="12" r="9" class="opacity-25" />
+              <path stroke-linecap="round" d="M21 12a9 9 0 0 0-9-9" />
+            </svg>
             {{ loading ? "Entrando..." : "Entrar" }}
           </button>
         </form>
@@ -154,3 +251,23 @@ async function onSubmit() {
     </main>
   </div>
 </template>
+
+<style scoped>
+.error-banner-enter-active,
+.error-banner-leave-active {
+  transition: none;
+}
+
+@media (prefers-reduced-motion: no-preference) {
+  .error-banner-enter-active,
+  .error-banner-leave-active {
+    transition: opacity 0.18s ease, transform 0.18s ease;
+  }
+
+  .error-banner-enter-from,
+  .error-banner-leave-to {
+    opacity: 0;
+    transform: translateY(-4px);
+  }
+}
+</style>

+ 3 - 4
src/views/pagina-inicial/PaginaInicialView.vue

@@ -46,10 +46,9 @@ async function onLandingSend() {
 
 
 const FRASES_EXEMPLO = [
-  "Qual é a política de férias da empresa?",
-  "Resuma o manual de integração de novos funcionários",
-  "Como solicito reembolso de despesas de viagem?",
-  "Quais são os benefícios disponíveis para colaboradores?",
+  "Como posso ajudar você hoje?",
+  "Qual a sua duvida?",
+  "Qual a configuração que você deseja saber?"
 ];
 const PLACEHOLDER_PADRAO = "Digite sua pergunta...";
 const placeholderTexto = ref(PLACEHOLDER_PADRAO);