leonardo 1 місяць тому
батько
коміт
68e034ebea

+ 1 - 0
docs/architecture.md

@@ -6,6 +6,7 @@
 frontend/src/
 ├── api/              Funções de chamada HTTP (uma por domínio)
 ├── components/       Componentes Vue reutilizáveis
+│   ├── auth/         FormLogin, PainelMarcaLogin (tela de login)
 │   ├── base/         Design system: BaseButton, BaseBadge, BaseModal
 │   └── skeletons/    Skeletons de carregamento
 ├── composables/      Estado e lógica reutilizável (useAuth, useChat, etc.)

+ 13 - 0
docs/components.md

@@ -341,6 +341,19 @@ resumo, justificativa, motivo do cancelamento, itens a melhorar e visita técnic
 
 ---
 
+## Login (FormLogin / PainelMarcaLogin)
+
+**Pasta:** `src/components/auth/`
+
+`LoginView` é só o layout split: painel de marca à esquerda (≥ `lg`), coluna do formulário à direita e rodapé com a versão (`__APP_VERSION__`, injetado pelo `define` do `vite.config.js` a partir do `version` do `package.json`).
+
+| Componente | Uso |
+|---|---|
+| `FormLogin.vue` | Formulário completo e toda a lógica de login: bloqueio com contagem regressiva (`locked` + `retryAfterSeconds`), aviso de Caps Lock, mostrar/ocultar senha, redirect seguro via `?redirect=`, modal "Esqueci minha senha" |
+| `PainelMarcaLogin.vue` | Painel decorativo de marca (`bg-primary`, `dark:bg-abyss-900`). Prop `compacto` (`Boolean`, padrão `false`) renderiza o card de marca do mobile (`lg:hidden`) em vez do painel lateral |
+
+---
+
 ## Skeletons
 
 **Pasta:** `src/components/skeletons/`

+ 2 - 1
eslint.config.js

@@ -13,7 +13,8 @@ export default [
     languageOptions: {
       ecmaVersion: "latest",
       sourceType: "module",
-      globals: { ...globals.browser }
+      // __APP_VERSION__ é injetado pelo `define` do vite.config.js
+      globals: { ...globals.browser, __APP_VERSION__: "readonly" }
     },
     rules: {
       // o que esta config existe para pegar: o `npm run build` passa com identificador

+ 1 - 0
package.json

@@ -1,5 +1,6 @@
 {
   "name": "ia-empresa-frontend",
+  "version": "1.0.0",
   "private": true,
   "type": "module",
   "scripts": {

+ 273 - 0
src/components/auth/FormLogin.vue

@@ -0,0 +1,273 @@
+<script setup>
+import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
+import { useRoute, useRouter } from "vue-router";
+import ModalEsqueciSenha from "../ModalEsqueciSenha.vue";
+import BaseButton from "../base/BaseButton.vue";
+import BaseIcon from "../base/BaseIcon.vue";
+import { Eye, EyeOff } from "lucide-vue-next";
+import { useAuth } from "../../composables/useAuth.js";
+
+const router = useRouter();
+const route = useRoute();
+const { login } = useAuth();
+
+const form = ref({
+  login: "",
+  senha: "",
+  rememberMe: false
+});
+const loading = ref(false);
+const errorCategory = ref("");
+const retryAfter = ref(0);
+const senhaVisivel = ref(false);
+const capsLockOn = ref(false);
+const duracaoBloqueio = ref("");
+const loginInputRef = ref(null);
+const senhaInputRef = ref(null);
+const modalEsqueciSenha = ref(false);
+
+let countdownTimer = null;
+let bloqueadoAte = 0;
+
+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(() => {
+  // em touch o autofocus abre o teclado e cobre o formulário
+  if (!window.matchMedia?.("(pointer: coarse)").matches) loginInputRef.value?.focus();
+  document.addEventListener("visibilitychange", atualizarCountdown);
+});
+
+onUnmounted(() => {
+  clearInterval(countdownTimer);
+  document.removeEventListener("visibilitychange", atualizarCountdown);
+});
+
+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") {
+    // o contador regressivo fica fora deste texto para o role="alert" não ser relido a cada segundo
+    return bloqueado.value
+      ? "Muitas tentativas incorretas. Tente novamente em"
+      : "Muitas tentativas incorretas. Aguarde alguns minutos e tente novamente.";
+  }
+  return ERROR_MESSAGES[errorCategory.value] ?? ERROR_MESSAGES.server;
+});
+
+const senhaDescribedBy = computed(() => {
+  const ids = [capsLockOn.value && "login-capslock", errorMessage.value && "login-error"].filter(Boolean);
+  return ids.length ? ids.join(" ") : undefined;
+});
+
+const canSubmit = computed(() => {
+  return Boolean(form.value.login.trim() && form.value.senha) && !bloqueado.value;
+});
+
+watch(() => [form.value.login, form.value.senha], () => {
+  if (errorCategory.value && errorCategory.value !== "locked") errorCategory.value = "";
+});
+
+function descreverDuracao(seconds) {
+  const min = Math.ceil(seconds / 60);
+  return min <= 1 ? "cerca de 1 minuto" : `cerca de ${min} minutos`;
+}
+
+// calculado a partir do prazo absoluto: setInterval é estrangulado em aba inativa e atrasaria o contador
+function atualizarCountdown() {
+  if (!bloqueadoAte) return;
+  retryAfter.value = Math.max(0, Math.ceil((bloqueadoAte - Date.now()) / 1000));
+  if (retryAfter.value <= 0) {
+    clearInterval(countdownTimer);
+    countdownTimer = null;
+    bloqueadoAte = 0;
+    errorCategory.value = "";
+  }
+}
+
+function startCountdown(seconds) {
+  clearInterval(countdownTimer);
+  bloqueadoAte = Date.now() + seconds * 1000;
+  duracaoBloqueio.value = descreverDuracao(seconds);
+  atualizarCountdown();
+  countdownTimer = setInterval(atualizarCountdown, 1000);
+}
+
+function onSenhaKeyEvent(event) {
+  capsLockOn.value = event.getModifierState?.("CapsLock") ?? false;
+}
+
+async function onSubmit() {
+  if (!canSubmit.value || loading.value) return;
+
+  loading.value = true;
+  errorCategory.value = "";
+
+  try {
+    await login(form.value);
+    const raw = typeof route.query.redirect === "string" ? route.query.redirect : "";
+    const redirect = raw.startsWith("/") && !raw.startsWith("//") ? raw : "/";
+    await router.replace(redirect);
+  } catch (err) {
+    errorCategory.value = err?.category ?? "server";
+    if (errorCategory.value === "locked" && Number(err?.retryAfterSeconds) > 0) {
+      startCountdown(Number(err.retryAfterSeconds));
+    }
+  } finally {
+    loading.value = false;
+  }
+
+  if (errorCategory.value === "invalid_credentials") {
+    // inputs ficam desabilitados durante o envio; espera reabilitar para focar
+    await nextTick();
+    senhaInputRef.value?.focus();
+    senhaInputRef.value?.select();
+  }
+}
+</script>
+
+<template>
+  <div class="flex flex-col gap-6">
+    <div>
+      <p class="mb-2 text-xs font-semibold uppercase tracking-widest text-muted-foreground">
+        Acesso restrito
+      </p>
+      <h1 class="text-2xl font-extrabold leading-tight tracking-tight">Entrar</h1>
+      <p class="mt-1.5 text-sm text-muted-foreground">
+        Informe seu usuário ou e-mail e sua senha para acessar o sistema.
+      </p>
+    </div>
+
+    <form class="grid gap-4" @submit.prevent="onSubmit">
+      <div class="grid gap-1.5">
+        <label for="login-usuario" class="text-sm font-medium">Usuário ou e-mail</label>
+        <input
+          id="login-usuario"
+          ref="loginInputRef"
+          v-model="form.login"
+          type="text"
+          name="username"
+          autocomplete="username"
+          placeholder="Digite seu login"
+          required
+          :disabled="loading"
+          aria-required="true"
+          :aria-invalid="errorMessage ? 'true' : undefined"
+          :aria-describedby="errorMessage ? 'login-error' : undefined"
+        />
+      </div>
+
+      <div class="grid gap-1.5">
+        <label for="login-senha" class="text-sm font-medium">Senha</label>
+        <div class="relative">
+          <input
+            id="login-senha"
+            ref="senhaInputRef"
+            v-model="form.senha"
+            :type="senhaVisivel ? 'text' : 'password'"
+            name="password"
+            autocomplete="current-password"
+            placeholder="Digite sua senha"
+            required
+            :disabled="loading"
+            aria-required="true"
+            :aria-invalid="errorMessage ? 'true' : undefined"
+            :aria-describedby="senhaDescribedBy"
+            class="pr-10"
+            @keydown="onSenhaKeyEvent"
+            @keyup="onSenhaKeyEvent"
+            @blur="capsLockOn = false"
+          />
+          <button
+            type="button"
+            class="absolute inset-y-0 right-0 flex items-center border-0 bg-transparent px-3 text-muted-foreground hover:text-foreground"
+            :aria-label="senhaVisivel ? 'Ocultar senha' : 'Mostrar senha'"
+            @click="senhaVisivel = !senhaVisivel"
+          >
+            <BaseIcon v-if="!senhaVisivel" :icon="Eye" class="h-4 w-4" />
+            <BaseIcon v-else :icon="EyeOff" class="h-4 w-4" />
+          </button>
+        </div>
+        <p
+          v-if="capsLockOn"
+          id="login-capslock"
+          role="status"
+          class="text-xs font-medium text-amber-600 dark:text-amber-400"
+        >
+          Caps Lock está ativado
+        </p>
+      </div>
+
+      <div class="flex items-center justify-between gap-2">
+        <label class="flex items-center gap-2 text-sm text-muted-foreground">
+          <input
+            v-model="form.rememberMe"
+            type="checkbox"
+            :disabled="loading"
+            class="!h-4 !w-4 rounded border-gray-300 !p-0"
+          />
+          <span>Manter conectado neste navegador</span>
+        </label>
+        <button
+          type="button"
+          class="border-0 bg-transparent text-sm font-medium text-primary hover:underline disabled:opacity-50 disabled:hover:no-underline"
+          :disabled="loading"
+          @click="modalEsqueciSenha = true"
+        >
+          Esqueci minha senha
+        </button>
+      </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 }}
+          <template v-if="errorCategory === 'locked' && bloqueado">
+            <span class="sr-only">{{ duracaoBloqueio }}.</span>
+            <span aria-hidden="true" class="font-mono">{{ tempoRestante }}.</span>
+          </template>
+        </div>
+      </Transition>
+
+      <BaseButton type="submit" size="lg" class="mt-2" :loading="loading" :disabled="!canSubmit || loading">
+        {{ loading ? "Entrando..." : "Entrar" }}
+      </BaseButton>
+    </form>
+
+    <ModalEsqueciSenha v-if="modalEsqueciSenha" @close="modalEsqueciSenha = false" />
+  </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>

+ 83 - 0
src/components/auth/PainelMarcaLogin.vue

@@ -0,0 +1,83 @@
+<script setup>
+import { Check } from "lucide-vue-next";
+import BaseIcon from "../base/BaseIcon.vue";
+import Logo from "../base/Logo.vue";
+
+defineProps({
+  compacto: { type: Boolean, default: false }
+});
+
+const RECURSOS = [
+  "Busca na base de documentos",
+  "Consulta a atendimentos registrados",
+  "Comandos diretos ao ERP",
+  "Avaliação de atendimentos por IA"
+];
+
+const GRADE_STYLE = {
+  backgroundImage:
+    "linear-gradient(to right, white 1px, transparent 1px), linear-gradient(to bottom, white 1px, transparent 1px)"
+};
+</script>
+
+<template>
+  <!-- Mobile: faixa de marca enxuta acima do formulário (headline e recursos só no desktop) -->
+  <div
+    v-if="compacto"
+    class="relative overflow-hidden bg-primary px-6 py-6 text-white lg:hidden dark:bg-abyss-900"
+  >
+    <div
+      aria-hidden="true"
+      class="absolute inset-0 bg-[length:40px_40px] opacity-[0.06]"
+      :style="GRADE_STYLE"
+    />
+    <div aria-hidden="true" class="absolute -right-16 -top-16 h-48 w-48 rounded-full bg-white/5" />
+
+    <div class="relative z-10">
+      <Logo />
+      <p class="mt-1 text-[11px] font-semibold uppercase tracking-widest text-white/60">
+        Assistente interno
+      </p>
+    </div>
+  </div>
+
+  <!-- Desktop: painel lateral de marca -->
+  <aside
+    v-else
+    class="relative hidden flex-col justify-between overflow-hidden bg-primary p-10 text-white lg:flex lg:w-5/12 xl:w-[42%] xl:p-14 dark:bg-abyss-900"
+  >
+    <div
+      aria-hidden="true"
+      class="absolute inset-0 bg-[length:48px_48px] opacity-[0.06]"
+      :style="GRADE_STYLE"
+    />
+    <div aria-hidden="true" class="absolute -left-32 -top-32 h-80 w-80 rounded-full bg-white/5" />
+    <div aria-hidden="true" class="absolute -bottom-20 -right-20 h-64 w-64 rounded-full bg-white/5" />
+    <div aria-hidden="true" class="absolute -right-16 top-1/3 h-48 w-48 rounded-full bg-white/5" />
+
+    <div class="relative z-10">
+      <Logo />
+    </div>
+
+    <div class="relative z-10 flex flex-1 flex-col justify-center py-12">
+      <p class="mb-4 text-xs font-semibold uppercase tracking-widest text-white/60">
+        Assistente interno
+      </p>
+      <p class="mb-5 text-3xl font-bold leading-tight xl:text-4xl">
+        Respostas da base da Star,<br />direto no chat.
+      </p>
+      <p class="mb-10 max-w-xs text-sm leading-relaxed text-white/70">
+        Documentos, histórico de atendimentos e dados do ERP reunidos em um só lugar.
+      </p>
+
+      <ul class="space-y-3.5">
+        <li v-for="item in RECURSOS" :key="item" class="flex items-center gap-3 text-sm text-white/80">
+          <span class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-white/15">
+            <BaseIcon :icon="Check" class="h-3 w-3 text-white" />
+          </span>
+          {{ item }}
+        </li>
+      </ul>
+    </div>
+  </aside>
+</template>

+ 19 - 246
src/views/auth/LoginView.vue

@@ -1,259 +1,32 @@
 <script setup>
-import { computed, onMounted, onUnmounted, ref, watch } from "vue";
-import { useRoute, useRouter } from "vue-router";
 import BotaoAlterarTema from "../../components/BotaoAlterarTema.vue";
-import DecorativeGradient from "../../components/DecorativeGradient.vue";
-import ModalEsqueciSenha from "../../components/ModalEsqueciSenha.vue";
-import BaseButton from "../../components/base/BaseButton.vue";
-import BaseIcon from "../../components/base/BaseIcon.vue";
-import Logo from "../../components/base/Logo.vue";
-import { LocateFixed, Eye, EyeOff } from "lucide-vue-next";
-import { useAuth } from "../../composables/useAuth.js";
+import FormLogin from "../../components/auth/FormLogin.vue";
+import PainelMarcaLogin from "../../components/auth/PainelMarcaLogin.vue";
 
-const router = useRouter();
-const route = useRoute();
-const { login } = useAuth();
-
-const form = ref({
-  login: "",
-  senha: "",
-  rememberMe: false
-});
-const loading = ref(false);
-const errorCategory = ref("");
-const retryAfter = ref(0);
-const senhaVisivel = ref(false);
-const capsLockOn = ref(false);
-const loginInputRef = ref(null);
-const modalEsqueciSenha = ref(false);
-
-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) && !bloqueado.value;
-});
-
-watch(() => [form.value.login, form.value.senha], () => {
-  
-  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;
-  errorCategory.value = "";
-
-  try {
-    await login(form.value);
-    const raw = typeof route.query.redirect === "string" ? route.query.redirect : "";
-    const redirect = raw.startsWith("/") && !raw.startsWith("//") ? raw : "/";
-    await router.replace(redirect);
-  } catch (err) {
-    errorCategory.value = err?.category ?? "server";
-    if (errorCategory.value === "locked" && Number(err?.retryAfterSeconds) > 0) {
-      startCountdown(Number(err.retryAfterSeconds));
-    }
-  } finally {
-    loading.value = false;
-  }
-}
+const versao = __APP_VERSION__;
 </script>
 
 <template>
-  <div class="relative min-h-screen overflow-hidden bg-background text-foreground">
-    <DecorativeGradient />
+  <div class="flex min-h-dvh flex-col bg-background text-foreground lg:flex-row">
+    <PainelMarcaLogin />
 
-    <header class="relative z-10 flex items-center justify-between px-6 py-5">
-      <div>
-        <Logo />
-        <div class="mt-1 text-sm text-muted-foreground">
-          Acesso ao assistente interno
-        </div>
-      </div>
+    <div class="relative flex min-w-0 flex-1 flex-col">
+      <PainelMarcaLogin compacto />
 
-      <BotaoAlterarTema />
-    </header>
+      <!-- no mobile o botão fica sobre o card de marca (bg-primary); o botão herda a cor via !text-current -->
+      <div class="absolute right-4 top-4 z-20 max-lg:text-white">
+        <BotaoAlterarTema />
+      </div>
 
-    <main class="relative z-10 flex min-h-[calc(100vh-88px)] items-center justify-center px-4 pb-8">
-      <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-abyss-900 dark:to-abyss-950 dark:shadow-[0_18px_50px_rgba(0,0,0,0.35)]"
-      >
-        <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"
-          >
-            <BaseIcon :icon="LocateFixed" class="h-6 w-6" />
-          </div>
-          <div>
-            <div class="text-2xl font-extrabold tracking-tight">Entrar</div>
-            <div class="text-sm text-muted-foreground">
-              Informe seu usuário ou e-mail e sua senha para acessar o sistema.
-            </div>
-          </div>
+      <main class="flex flex-1 items-center justify-center px-4 py-10">
+        <div class="w-full max-w-sm">
+          <FormLogin />
         </div>
+      </main>
 
-        <form class="mt-6 grid gap-4" @submit.prevent="onSubmit">
-          <label class="grid gap-1.5">
-            <span class="text-sm font-medium">Usuário ou e-mail</span>
-            <input
-              ref="loginInputRef"
-              v-model="form.login"
-              type="text"
-              name="username"
-              autocomplete="username"
-              placeholder="Digite seu login"
-              required
-              aria-required="true"
-              :aria-invalid="errorMessage ? 'true' : undefined"
-              :aria-describedby="errorMessage ? 'login-error' : undefined"
-            />
-          </label>
-
-          <label class="grid gap-1.5">
-            <span class="text-sm font-medium">Senha</span>
-            <div class="relative">
-              <input
-                v-model="form.senha"
-                :type="senhaVisivel ? 'text' : 'password'"
-                name="password"
-                autocomplete="current-password"
-                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"
-                class="absolute inset-y-0 right-0 flex items-center border-0 bg-transparent px-3 text-gray-400 hover:text-muted-foreground dark:hover:text-gray-300"
-                :aria-label="senhaVisivel ? 'Ocultar senha' : 'Mostrar senha'"
-                @click="senhaVisivel = !senhaVisivel"
-              >
-                <BaseIcon v-if="!senhaVisivel" :icon="Eye" class="h-4 w-4" />
-                <BaseIcon v-else :icon="EyeOff" class="h-4 w-4" />
-              </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>
-
-          <div class="flex items-center justify-between gap-2">
-            <label class="flex items-center gap-2 text-sm text-muted-foreground">
-              <input
-                v-model="form.rememberMe"
-                type="checkbox"
-                class="!h-4 !w-4 rounded border-gray-300 !p-0"
-              />
-              <span>Manter conectado neste navegador</span>
-            </label>
-            <button
-              type="button"
-              class="border-0 bg-transparent text-sm font-medium text-primary hover:underline"
-              @click="modalEsqueciSenha = true"
-            >
-              Esqueci minha senha
-            </button>
-          </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>
-
-          <BaseButton type="submit" size="lg" class="mt-2" :loading="loading" :disabled="!canSubmit || loading">
-            {{ loading ? "Entrando..." : "Entrar" }}
-          </BaseButton>
-        </form>
-      </div>
-    </main>
-
-    <ModalEsqueciSenha v-if="modalEsqueciSenha" @close="modalEsqueciSenha = false" />
+      <footer class="pb-6 text-center font-mono text-xs text-muted-foreground">
+        Oráculo · v{{ versao }}
+      </footer>
+    </div>
   </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>

+ 5 - 0
vite.config.js

@@ -5,10 +5,15 @@ import tailwindcss from "@tailwindcss/postcss";
 import autoprefixer from "autoprefixer";
 import { fileURLToPath } from "node:url";
 import path from "node:path";
+import { readFileSync } from "node:fs";
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const pkg = JSON.parse(readFileSync(path.resolve(__dirname, "package.json"), "utf-8"));
 
 export default defineConfig(({ command }) => ({
+  define: {
+    __APP_VERSION__: JSON.stringify(pkg.version)
+  },
   resolve: {
     alias: {
       "@": path.resolve(__dirname, "src")