LoginView.vue 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. <script setup>
  2. import { computed, onMounted, onUnmounted, ref, watch } from "vue";
  3. import { useRoute, useRouter } from "vue-router";
  4. import BotaoAlterarTema from "../../components/BotaoAlterarTema.vue";
  5. import DecorativeGradient from "../../components/DecorativeGradient.vue";
  6. import ModalEsqueciSenha from "../../components/ModalEsqueciSenha.vue";
  7. import BaseButton from "../../components/base/BaseButton.vue";
  8. import BaseIcon from "../../components/base/BaseIcon.vue";
  9. import Logo from "../../components/base/Logo.vue";
  10. import { LocateFixed, Eye, EyeOff } from "lucide-vue-next";
  11. import { useAuth } from "../../composables/useAuth.js";
  12. const router = useRouter();
  13. const route = useRoute();
  14. const { login } = useAuth();
  15. const form = ref({
  16. login: "",
  17. senha: "",
  18. rememberMe: false
  19. });
  20. const loading = ref(false);
  21. const errorCategory = ref("");
  22. const retryAfter = ref(0);
  23. const senhaVisivel = ref(false);
  24. const capsLockOn = ref(false);
  25. const loginInputRef = ref(null);
  26. const modalEsqueciSenha = ref(false);
  27. let countdownTimer = null;
  28. const ERROR_MESSAGES = {
  29. invalid_credentials: "Usuário ou senha incorretos. Confira os dados e tente novamente.",
  30. network: "Não foi possível conectar ao servidor. Verifique sua conexão e tente novamente.",
  31. server: "O servidor está indisponível no momento. Tente novamente em instantes."
  32. };
  33. onMounted(() => {
  34. loginInputRef.value?.focus();
  35. });
  36. onUnmounted(() => {
  37. clearInterval(countdownTimer);
  38. });
  39. const bloqueado = computed(() => retryAfter.value > 0);
  40. const tempoRestante = computed(() => {
  41. const min = Math.floor(retryAfter.value / 60);
  42. const seg = retryAfter.value % 60;
  43. return `${String(min).padStart(2, "0")}:${String(seg).padStart(2, "0")}`;
  44. });
  45. const errorMessage = computed(() => {
  46. if (!errorCategory.value) return "";
  47. if (errorCategory.value === "locked") {
  48. return bloqueado.value
  49. ? `Muitas tentativas incorretas. Tente novamente em ${tempoRestante.value}.`
  50. : "Muitas tentativas incorretas. Aguarde alguns minutos e tente novamente.";
  51. }
  52. return ERROR_MESSAGES[errorCategory.value] ?? ERROR_MESSAGES.server;
  53. });
  54. const canSubmit = computed(() => {
  55. return Boolean(form.value.login.trim() && form.value.senha) && !bloqueado.value;
  56. });
  57. watch(() => [form.value.login, form.value.senha], () => {
  58. if (errorCategory.value && errorCategory.value !== "locked") errorCategory.value = "";
  59. });
  60. function startCountdown(seconds) {
  61. clearInterval(countdownTimer);
  62. retryAfter.value = seconds;
  63. countdownTimer = setInterval(() => {
  64. retryAfter.value -= 1;
  65. if (retryAfter.value <= 0) {
  66. clearInterval(countdownTimer);
  67. countdownTimer = null;
  68. retryAfter.value = 0;
  69. errorCategory.value = "";
  70. }
  71. }, 1000);
  72. }
  73. function onSenhaKeyEvent(event) {
  74. capsLockOn.value = event.getModifierState?.("CapsLock") ?? false;
  75. }
  76. async function onSubmit() {
  77. if (!canSubmit.value || loading.value) return;
  78. loading.value = true;
  79. errorCategory.value = "";
  80. try {
  81. await login(form.value);
  82. const raw = typeof route.query.redirect === "string" ? route.query.redirect : "";
  83. const redirect = raw.startsWith("/") && !raw.startsWith("//") ? raw : "/";
  84. await router.replace(redirect);
  85. } catch (err) {
  86. errorCategory.value = err?.category ?? "server";
  87. if (errorCategory.value === "locked" && Number(err?.retryAfterSeconds) > 0) {
  88. startCountdown(Number(err.retryAfterSeconds));
  89. }
  90. } finally {
  91. loading.value = false;
  92. }
  93. }
  94. </script>
  95. <template>
  96. <div class="relative min-h-screen overflow-hidden bg-background text-foreground">
  97. <DecorativeGradient />
  98. <header class="relative z-10 flex items-center justify-between px-6 py-5">
  99. <div>
  100. <Logo />
  101. <div class="mt-1 text-sm text-muted-foreground">
  102. Acesso ao assistente interno
  103. </div>
  104. </div>
  105. <BotaoAlterarTema />
  106. </header>
  107. <main class="relative z-10 flex min-h-[calc(100vh-88px)] items-center justify-center px-4 pb-8">
  108. <div
  109. 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)]"
  110. >
  111. <div class="flex items-center gap-3">
  112. <div
  113. aria-hidden="true"
  114. class="flex h-11 w-11 items-center justify-center rounded-2xl bg-primary/10 text-primary dark:bg-primary/20"
  115. >
  116. <BaseIcon :icon="LocateFixed" class="h-6 w-6" />
  117. </div>
  118. <div>
  119. <div class="text-2xl font-extrabold tracking-tight">Entrar</div>
  120. <div class="text-sm text-muted-foreground">
  121. Informe seu usuario ou email e sua senha para acessar o sistema.
  122. </div>
  123. </div>
  124. </div>
  125. <form class="mt-6 grid gap-4" @submit.prevent="onSubmit">
  126. <label class="grid gap-1.5">
  127. <span class="text-sm font-medium">Usuario ou email</span>
  128. <input
  129. ref="loginInputRef"
  130. v-model="form.login"
  131. type="text"
  132. name="username"
  133. autocomplete="username"
  134. placeholder="Digite seu login"
  135. required
  136. aria-required="true"
  137. :aria-invalid="errorMessage ? 'true' : undefined"
  138. :aria-describedby="errorMessage ? 'login-error' : undefined"
  139. />
  140. </label>
  141. <label class="grid gap-1.5">
  142. <span class="text-sm font-medium">Senha</span>
  143. <div class="relative">
  144. <input
  145. v-model="form.senha"
  146. :type="senhaVisivel ? 'text' : 'password'"
  147. name="password"
  148. autocomplete="current-password"
  149. placeholder="Digite sua senha"
  150. required
  151. aria-required="true"
  152. :aria-invalid="errorMessage ? 'true' : undefined"
  153. :aria-describedby="errorMessage ? 'login-error' : undefined"
  154. class="pr-10"
  155. @keydown="onSenhaKeyEvent"
  156. @keyup="onSenhaKeyEvent"
  157. @blur="capsLockOn = false"
  158. />
  159. <button
  160. type="button"
  161. 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"
  162. :aria-label="senhaVisivel ? 'Ocultar senha' : 'Mostrar senha'"
  163. @click="senhaVisivel = !senhaVisivel"
  164. >
  165. <BaseIcon v-if="!senhaVisivel" :icon="Eye" class="h-4 w-4" />
  166. <BaseIcon v-else :icon="EyeOff" class="h-4 w-4" />
  167. </button>
  168. </div>
  169. <p
  170. v-if="capsLockOn"
  171. role="status"
  172. class="text-xs font-medium text-amber-600 dark:text-amber-400"
  173. >
  174. Caps Lock está ativado
  175. </p>
  176. </label>
  177. <div class="flex items-center justify-between gap-2">
  178. <label class="flex items-center gap-2 text-sm text-muted-foreground">
  179. <input
  180. v-model="form.rememberMe"
  181. type="checkbox"
  182. class="!h-4 !w-4 rounded border-gray-300 !p-0"
  183. />
  184. <span>Manter conectado neste navegador</span>
  185. </label>
  186. <button
  187. type="button"
  188. class="border-0 bg-transparent text-sm font-medium text-primary hover:underline"
  189. @click="modalEsqueciSenha = true"
  190. >
  191. Esqueci minha senha
  192. </button>
  193. </div>
  194. <Transition name="error-banner">
  195. <div
  196. v-if="errorMessage"
  197. id="login-error"
  198. role="alert"
  199. 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"
  200. >
  201. {{ errorMessage }}
  202. </div>
  203. </Transition>
  204. <BaseButton type="submit" size="lg" class="mt-2" :loading="loading" :disabled="!canSubmit || loading">
  205. {{ loading ? "Entrando..." : "Entrar" }}
  206. </BaseButton>
  207. </form>
  208. </div>
  209. </main>
  210. <ModalEsqueciSenha v-if="modalEsqueciSenha" @close="modalEsqueciSenha = false" />
  211. </div>
  212. </template>
  213. <style scoped>
  214. .error-banner-enter-active,
  215. .error-banner-leave-active {
  216. transition: none;
  217. }
  218. @media (prefers-reduced-motion: no-preference) {
  219. .error-banner-enter-active,
  220. .error-banner-leave-active {
  221. transition: opacity 0.18s ease, transform 0.18s ease;
  222. }
  223. .error-banner-enter-from,
  224. .error-banner-leave-to {
  225. opacity: 0;
  226. transform: translateY(-4px);
  227. }
  228. }
  229. </style>