Bladeren bron

ajustes login

leonardo 2 maanden geleden
bovenliggende
commit
4d5ac36d98

+ 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-ChzCvQuO.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-DJ5kheLn.css">
+    <script type="module" crossorigin src="/assets/index-BEEbqIFG.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-Npqe4EPc.css">
   </head>
   <body class="bg-background">
     <noscript>

+ 25 - 0
src/api/auth.js

@@ -39,6 +39,31 @@ export async function loginRequest({ login, senha, rememberMe = false }) {
   return payload;
 }
 
+export async function solicitarResetSenha({ login }) {
+  let res;
+  try {
+    res = await fetch(`${apiBaseUrl}/api/auth/esqueci-senha`, {
+      method: "POST",
+      headers: {
+        "content-type": "application/json"
+      },
+      body: JSON.stringify({ login })
+    });
+  } catch {
+    throw new ApiError("network", "network_error");
+  }
+
+  const payload = await parseResponse(res);
+
+  if (!res.ok || payload?.status === false) {
+    throw new ApiError(categorizeHttpStatus(res.status), extractErrorMessage(payload, res.status), {
+      retryAfterSeconds: Number(payload?.retryAfterSeconds) || 0
+    });
+  }
+
+  return payload;
+}
+
 export async function refreshTokenRequest({ refreshToken }) {
   const res = await fetch(`${apiBaseUrl}/api/auth/refresh`, {
     method: "POST",

+ 13 - 0
src/api/users.js

@@ -16,3 +16,16 @@ export async function updateUser(id, data) {
 export async function toggleUserStatus(id) {
   return apiFetch(`/api/users/${id}/status`, { method: "PATCH" });
 }
+
+export async function alterarSenhaUsuario(id, data) {
+  return apiFetch(`/api/users/${id}/senha`, { method: "PATCH", body: data });
+}
+
+export async function listarSolicitacoesReset() {
+  const payload = await apiFetch("/api/users/solicitacoes-reset");
+  return Array.isArray(payload?.items) ? payload.items : [];
+}
+
+export async function dispensarSolicitacaoReset(id) {
+  return apiFetch(`/api/users/solicitacoes-reset/${id}/dispensar`, { method: "PATCH" });
+}

+ 107 - 0
src/components/ModalEsqueciSenha.vue

@@ -0,0 +1,107 @@
+<template>
+  <Teleport to="body">
+    <div class="fixed inset-0 z-[9999] flex items-center justify-center p-4" @click="!enviando && $emit('close')">
+      <div class="absolute inset-0 bg-black/40 dark:bg-black/60" />
+      <div
+        class="relative w-full max-w-md overflow-hidden rounded-2xl border border-gray-200 bg-background text-foreground shadow-xl dark:border-gray-700"
+        @click.stop
+      >
+        <div class="flex items-center justify-between gap-3 border-b border-gray-200 px-5 py-4 dark:border-gray-700">
+          <h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">
+            Esqueci minha senha
+          </h3>
+          <button
+            type="button"
+            :disabled="enviando"
+            class="rounded-lg border border-transparent p-1.5 text-gray-500 transition-colors hover:border-gray-200 hover:bg-gray-100 disabled:opacity-50 dark:text-gray-400 dark:hover:border-gray-700 dark:hover:bg-white/5"
+            @click="$emit('close')"
+          >
+            <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
+            </svg>
+          </button>
+        </div>
+
+        <div v-if="enviado" class="grid gap-4 p-5">
+          <div class="rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-3 text-sm text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-300">
+            Se o usuário existir, a solicitação foi registrada. Um administrador entrará em contato pelo canal interno.
+          </div>
+          <div class="flex justify-end">
+            <BaseButton @click="$emit('close')">
+              Fechar
+            </BaseButton>
+          </div>
+        </div>
+
+        <form v-else class="grid gap-4 p-5" @submit.prevent="onSubmit">
+          <p class="text-sm text-gray-500 dark:text-gray-400">
+            Informe seu usuário ou e-mail. Um administrador será notificado para redefinir sua senha.
+          </p>
+
+          <div class="grid gap-1.5">
+            <label for="mes-login" class="text-sm font-medium text-gray-700 dark:text-gray-300">Usuário ou e-mail</label>
+            <input
+              id="mes-login"
+              ref="loginInputRef"
+              v-model="login"
+              placeholder="Digite seu login"
+              required
+              :disabled="enviando"
+              autocomplete="username"
+            />
+          </div>
+
+          <div v-if="erro" class="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-300">
+            {{ erro }}
+          </div>
+
+          <div class="flex justify-end gap-2 pt-1">
+            <BaseButton variant="secondary" :disabled="enviando" @click="$emit('close')">
+              Cancelar
+            </BaseButton>
+            <BaseButton type="submit" :disabled="enviando || !login.trim()" :loading="enviando">
+              Enviar solicitação
+            </BaseButton>
+          </div>
+        </form>
+      </div>
+    </div>
+  </Teleport>
+</template>
+
+<script setup>
+import { onMounted, ref } from "vue";
+import { solicitarResetSenha } from "../api/auth.js";
+import BaseButton from "./base/BaseButton.vue";
+
+defineEmits(["close"]);
+
+const login = ref("");
+const enviando = ref(false);
+const enviado = ref(false);
+const erro = ref("");
+const loginInputRef = ref(null);
+
+onMounted(() => {
+  loginInputRef.value?.focus();
+});
+
+async function onSubmit() {
+  if (enviando.value) return;
+  erro.value = "";
+  enviando.value = true;
+  try {
+    await solicitarResetSenha({ login: login.value.trim() });
+    enviado.value = true;
+  } catch (e) {
+    erro.value =
+      e?.category === "locked" || Number(e?.retryAfterSeconds) > 0
+        ? "Muitas solicitações. Tente novamente em alguns minutos."
+        : e?.category === "network"
+          ? "Não foi possível conectar ao servidor. Verifique sua conexão."
+          : "Não foi possível enviar a solicitação. Tente novamente.";
+  } finally {
+    enviando.value = false;
+  }
+}
+</script>

+ 113 - 0
src/components/ModalRedefinirSenha.vue

@@ -0,0 +1,113 @@
+<template>
+  <Teleport to="body">
+    <div class="fixed inset-0 z-[9999] flex items-center justify-center p-4" @click="!salvando && $emit('close')">
+      <div class="absolute inset-0 bg-black/40 dark:bg-black/60" />
+      <div
+        class="relative w-full max-w-md overflow-hidden rounded-2xl border border-gray-200 bg-background text-foreground shadow-xl dark:border-gray-700"
+        @click.stop
+      >
+        <div class="flex items-center justify-between gap-3 border-b border-gray-200 px-5 py-4 dark:border-gray-700">
+          <h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">
+            Redefinir senha de {{ usuario.Nome || usuario.Login }}
+          </h3>
+          <button
+            type="button"
+            :disabled="salvando"
+            class="rounded-lg border border-transparent p-1.5 text-gray-500 transition-colors hover:border-gray-200 hover:bg-gray-100 disabled:opacity-50 dark:text-gray-400 dark:hover:border-gray-700 dark:hover:bg-white/5"
+            @click="$emit('close')"
+          >
+            <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
+            </svg>
+          </button>
+        </div>
+
+        <form class="grid gap-4 p-5" @submit.prevent="onSubmit">
+          <div class="grid gap-1.5">
+            <label for="mrs-senha" class="text-sm font-medium text-gray-700 dark:text-gray-300">Nova senha</label>
+            <input
+              id="mrs-senha"
+              v-model="senhaNova"
+              type="password"
+              placeholder="Mínimo 6 caracteres"
+              required
+              minlength="6"
+              :disabled="salvando"
+              autocomplete="new-password"
+            />
+          </div>
+
+          <div class="grid gap-1.5">
+            <label for="mrs-confirmacao" class="text-sm font-medium text-gray-700 dark:text-gray-300">Confirmar nova senha</label>
+            <input
+              id="mrs-confirmacao"
+              v-model="confirmacao"
+              type="password"
+              placeholder="Repita a nova senha"
+              required
+              :disabled="salvando"
+              autocomplete="new-password"
+            />
+          </div>
+
+          <div v-if="erroForm" class="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-300">
+            {{ erroForm }}
+          </div>
+
+          <p class="text-xs text-gray-500 dark:text-gray-400">
+            Comunique a nova senha ao usuário por canal interno.
+          </p>
+
+          <div class="flex justify-end gap-2 pt-1">
+            <BaseButton variant="secondary" :disabled="salvando" @click="$emit('close')">
+              Cancelar
+            </BaseButton>
+            <BaseButton type="submit" :disabled="salvando" :loading="salvando">
+              Redefinir senha
+            </BaseButton>
+          </div>
+        </form>
+      </div>
+    </div>
+  </Teleport>
+</template>
+
+<script setup>
+import { ref } from "vue";
+import { alterarSenhaUsuario } from "../api/users.js";
+import BaseButton from "./base/BaseButton.vue";
+
+const props = defineProps({
+  usuario: { type: Object, required: true }
+});
+
+const emit = defineEmits(["close", "saved"]);
+
+const senhaNova = ref("");
+const confirmacao = ref("");
+const salvando = ref(false);
+const erroForm = ref("");
+
+async function onSubmit() {
+  erroForm.value = "";
+
+  if (senhaNova.value.length < 6) {
+    erroForm.value = "A nova senha deve ter no mínimo 6 caracteres.";
+    return;
+  }
+  if (senhaNova.value !== confirmacao.value) {
+    erroForm.value = "As senhas não conferem.";
+    return;
+  }
+
+  salvando.value = true;
+  try {
+    await alterarSenhaUsuario(props.usuario.Id, { senhaNova: senhaNova.value });
+    emit("saved");
+  } catch (e) {
+    erroForm.value = e?.message || "Erro ao redefinir a senha.";
+  } finally {
+    salvando.value = false;
+  }
+}
+</script>

+ 21 - 8
src/views/auth/LoginView.vue

@@ -2,6 +2,7 @@
 import { computed, onMounted, onUnmounted, ref, watch } from "vue";
 import { useRoute, useRouter } from "vue-router";
 import BotaoAlterarTema from "../../components/BotaoAlterarTema.vue";
+import ModalEsqueciSenha from "../../components/ModalEsqueciSenha.vue";
 import { useAuth } from "../../composables/useAuth.js";
 
 const router = useRouter();
@@ -19,6 +20,7 @@ const retryAfter = ref(0);
 const senhaVisivel = ref(false);
 const capsLockOn = ref(false);
 const loginInputRef = ref(null);
+const modalEsqueciSenha = ref(false);
 
 let countdownTimer = null;
 
@@ -207,14 +209,23 @@ async function onSubmit() {
             </p>
           </label>
 
-          <label class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
-            <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>
+          <div class="flex items-center justify-between gap-2">
+            <label class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
+              <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
@@ -249,6 +260,8 @@ async function onSubmit() {
         </form>
       </div>
     </main>
+
+    <ModalEsqueciSenha v-if="modalEsqueciSenha" @close="modalEsqueciSenha = false" />
   </div>
 </template>
 

+ 113 - 2
src/views/usuarios/UsuariosView.vue

@@ -2,8 +2,10 @@
 import { computed, onMounted, ref } from "vue";
 import LayoutSistema from "../../layout/LayoutSistema.vue";
 import ModalUsuario from "../../components/ModalUsuario.vue";
+import ModalRedefinirSenha from "../../components/ModalRedefinirSenha.vue";
 import { useUsers } from "../../composables/useUsers.js";
 import { useToast } from "../../composables/useToast.js";
+import { listarSolicitacoesReset, dispensarSolicitacaoReset } from "../../api/users.js";
 import BaseButton from "../../components/base/BaseButton.vue";
 import BaseBadge from "../../components/base/BaseBadge.vue";
 import { avatarIniciais, avatarCor } from "../../utils/avatar.js";
@@ -14,6 +16,15 @@ const toast = useToast();
 const totalUsuarios = computed(() => users.value?.length ?? 0);
 const modalAberto = ref(false);
 const usuarioEmEdicao = ref(null);
+const modalSenhaAberto = ref(false);
+const usuarioRedefinicao = ref(null);
+const solicitacoes = ref([]);
+
+const solicitacaoPorUsuario = computed(() => {
+  const map = new Map();
+  for (const s of solicitacoes.value) map.set(s.UsuarioId, s);
+  return map;
+});
 
 const busca = ref("");
 const filtroStatus = ref("todos");
@@ -78,8 +89,57 @@ function aoSalvarUsuario() {
   fecharModal();
 }
 
+async function carregarSolicitacoes() {
+  try {
+    solicitacoes.value = await listarSolicitacoesReset();
+  } catch {
+    solicitacoes.value = [];
+  }
+}
+
+function abrirRedefinicaoSenha(usuario) {
+  usuarioRedefinicao.value = usuario;
+  modalSenhaAberto.value = true;
+}
+
+function fecharModalSenha() {
+  modalSenhaAberto.value = false;
+  usuarioRedefinicao.value = null;
+}
+
+async function aoRedefinirSenha() {
+  toast.sucesso("Senha redefinida com sucesso. Comunique o usuário.");
+  fecharModalSenha();
+  await carregarSolicitacoes();
+}
+
+async function dispensarSolicitacao(solicitacao) {
+  try {
+    await dispensarSolicitacaoReset(solicitacao.Id);
+    toast.sucesso("Solicitação dispensada.");
+    await carregarSolicitacoes();
+  } catch (e) {
+    toast.erro(e?.message || "Erro ao dispensar solicitação.");
+  }
+}
+
+function usuarioDaSolicitacao(solicitacao) {
+  return (
+    (users.value ?? []).find((u) => u.Id === solicitacao.UsuarioId) ??
+    solicitacao.usuario ??
+    { Id: solicitacao.UsuarioId }
+  );
+}
+
+function formatarDataSolicitacao(valor) {
+  const data = new Date(valor);
+  return Number.isNaN(data.getTime())
+    ? ""
+    : data.toLocaleString("pt-BR", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" });
+}
+
 onMounted(async () => {
-  await loadUsers();
+  await Promise.all([loadUsers(), carregarSolicitacoes()]);
 });
 </script>
 
@@ -111,7 +171,36 @@ onMounted(async () => {
         </div>
       </div>
 
-      
+      <div
+        v-if="solicitacoes.length"
+        class="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 dark:border-amber-500/20 dark:bg-amber-500/10"
+      >
+        <div class="text-sm font-semibold text-amber-800 dark:text-amber-300">
+          {{ solicitacoes.length }} solicitaç{{ solicitacoes.length === 1 ? "ão" : "ões" }} de redefinição de senha pendente{{ solicitacoes.length === 1 ? "" : "s" }}
+        </div>
+        <ul class="mt-2 grid gap-2">
+          <li
+            v-for="solicitacao in solicitacoes"
+            :key="solicitacao.Id"
+            class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"
+          >
+            <div class="text-sm text-amber-700 dark:text-amber-200">
+              <span class="font-medium">{{ solicitacao.usuario?.Nome || solicitacao.usuario?.Login || `Usuário #${solicitacao.UsuarioId}` }}</span>
+              <span v-if="solicitacao.usuario?.Login" class="text-amber-600/80 dark:text-amber-200/70"> ({{ solicitacao.usuario.Login }})</span>
+              <span class="text-amber-600/80 dark:text-amber-200/70"> — {{ formatarDataSolicitacao(solicitacao.CreatedAt) }}</span>
+            </div>
+            <div class="flex shrink-0 items-center gap-1.5">
+              <BaseButton size="sm" @click="abrirRedefinicaoSenha(usuarioDaSolicitacao(solicitacao))">
+                Redefinir senha
+              </BaseButton>
+              <BaseButton variant="ghost" size="sm" @click="dispensarSolicitacao(solicitacao)">
+                Dispensar
+              </BaseButton>
+            </div>
+          </li>
+        </ul>
+      </div>
+
       <div class="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
         <div class="relative flex-1">
           <svg
@@ -205,6 +294,9 @@ onMounted(async () => {
                   <span class="font-medium text-gray-900 dark:text-gray-100">
                     {{ usuario.Nome || usuario.Login || "Usuário sem nome" }}
                   </span>
+                  <BaseBadge v-if="solicitacaoPorUsuario.has(usuario.Id)" variant="warning">
+                    Reset pendente
+                  </BaseBadge>
                 </div>
               </td>
 
@@ -252,6 +344,18 @@ onMounted(async () => {
                       <path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" />
                     </svg>
                   </BaseButton>
+                  <BaseButton
+                    variant="ghost"
+                    size="sm"
+                    class="!px-2"
+                    title="Redefinir senha"
+                    aria-label="Redefinir senha"
+                    @click="abrirRedefinicaoSenha(usuario)"
+                  >
+                    <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
+                      <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 0 1 3 3m3 0a6 6 0 0 1-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1 1 21.75 8.25Z" />
+                    </svg>
+                  </BaseButton>
                   <BaseButton
                     :variant="String(usuario.Status) === '1' ? 'danger' : 'secondary'"
                     size="sm"
@@ -284,5 +388,12 @@ onMounted(async () => {
       @close="fecharModal"
       @saved="aoSalvarUsuario"
     />
+
+    <ModalRedefinirSenha
+      v-if="modalSenhaAberto && usuarioRedefinicao"
+      :usuario="usuarioRedefinicao"
+      @close="fecharModalSenha"
+      @saved="aoRedefinirSenha"
+    />
   </LayoutSistema>
 </template>