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