|
@@ -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>
|