Jelajahi Sumber

✨ feat(auth): implementar login multi-tipo, recuperação e verificação de senha

Fase: dev | Origin: melhoria-interna
Gustavo Zanatta 1 Minggu lalu
induk
melakukan
e95bfe5947

+ 1 - 0
src/composables/useAuth.js

@@ -56,5 +56,6 @@ export const useAuth = () => {
     login,
     logout,
     refresh,
+    setAuthDataFromPayload,
   };
 };

+ 2 - 3
src/pages/login/ForgotPasswordPage.vue

@@ -6,10 +6,9 @@
       <div class="column items-center fields-card">
         <q-img :src="Logo" class="login-logo q-mb-lg" />
 
-        <UserTypeBadge :tipo="tipo" />
-
         <p class="login-title">{{ $t("auth.forgot_password_title") }}</p>
-        <p class="login-description">{{ $t("auth.forgot_password_description") }}</p>
+        
+        <UserTypeBadge :tipo="tipo" />
 
         <q-form
           ref="formRef"

+ 1 - 1
src/pages/login/LoginPage.vue

@@ -113,7 +113,7 @@ const userTypes = [
 
 const getRedirectByTipo = (tipo) => {
   if (tipo === "administrador") return { name: "DashboardPage" };
-  if (tipo === "associado")     return { name: "HomePage" };
+  if (tipo === "associado")     return { name: "CarteirinhaPage" };
   if (tipo === "parceiro")      return { name: "HomePage" };
   return { name: "HomePage" };
 };

+ 15 - 1
src/pages/login/ResetPasswordPage.vue

@@ -56,6 +56,8 @@ import { ref, useTemplateRef } from "vue";
 import { useRouter, useRoute } from "vue-router";
 import { useInputRules } from "src/composables/useInputRules";
 import { useSubmitHandler } from "src/composables/useSubmitHandler";
+import { useAuth } from "src/composables/useAuth";
+import { userStore } from "src/stores/user";
 import { resetPassword } from "src/api/auth";
 
 import Logo from "src/assets/logo_serprati.svg";
@@ -65,6 +67,14 @@ import UserTypeBadge from "./components/UserTypeBadge.vue";
 const router = useRouter();
 const route  = useRoute();
 const { inputRules } = useInputRules();
+const { setAuthDataFromPayload } = useAuth();
+const store = userStore();
+
+const HOME_BY_TIPO = {
+  administrador: "DashboardPage",
+  associado:     "CarteirinhaPage",
+  parceiro:      "ValidarCarteirinhaPage",
+};
 
 const email  = route.query.email  ?? "";
 const tipo   = route.query.tipo   ?? "administrador";
@@ -77,7 +87,11 @@ const {
   validationErrors,
   execute: submitForm,
 } = useSubmitHandler({
-  onSuccess: () => router.push({ name: "LoginPage" }),
+  onSuccess: async (response) => {
+    await setAuthDataFromPayload(response.data.payload);
+    const home = HOME_BY_TIPO[store.userTipo] ?? "CarteirinhaPage";
+    router.push({ name: home });
+  },
   formRef,
 });
 

+ 133 - 68
src/pages/login/VerifyCodePage.vue

@@ -10,57 +10,56 @@
 
         <p class="login-title">{{ $t("auth.enter_code_title") }}</p>
 
-        <div class="email-box q-mb-lg">
-          <q-icon name="mdi-email-outline" size="16px" class="q-mr-xs" />
-          <span>{{ email }}</span>
+        <div class="code-input-container q-mb-md" @click="focusCodeInput">
+          <div class="code-slots row items-end justify-center">
+            <div
+              v-for="i in 6"
+              :key="i"
+              class="code-slot"
+              :class="{
+                'code-slot--filled': (form.codigo?.length ?? 0) >= i,
+                'code-slot--active': inputFocused && (form.codigo?.length ?? 0) === i - 1,
+              }"
+            >
+              <span class="code-char">{{ form.codigo?.[i - 1] ?? "" }}</span>
+            </div>
+          </div>
+          <input
+            ref="codeInputRef"
+            v-model="form.codigo"
+            class="code-hidden-input"
+            maxlength="6"
+            inputmode="numeric"
+            autocomplete="one-time-code"
+            :disabled="loading"
+            @focus="inputFocused = true"
+            @blur="inputFocused = false"
+            @input="onCodeInput"
+            @keydown="filterNonNumeric"
+          />
         </div>
 
-        <q-form
-          ref="formRef"
-          class="full-width"
-          autocomplete="off"
-          @submit="onSubmit"
-        >
-          <DefaultInput
-            v-model="form.codigo"
-            v-model:error="validationErrors.codigo"
-            autofocus
-            :label="$t('common.terms.code')"
-            mask="######"
-            :rules="[inputRules.required, (v) => (v && v.length === 6) || $t('validation.rules.code_length')]"
-          >
-            <template #append>
-              <q-icon name="mdi-numeric" color="grey-5" />
-            </template>
-          </DefaultInput>
-
-          <q-btn
-            class="full-width q-mt-sm login-btn"
-            color="violet-normal"
-            :label="$t('auth.verify')"
-            type="submit"
-            unelevated
-            :loading
-          >
-            <template #loading><q-spinner /></template>
-          </q-btn>
-        </q-form>
+        <div v-if="loading" class="q-mb-md">
+          <q-spinner color="violet-normal" size="24px" />
+        </div>
+
+        <p class="email-text q-py-md">{{ email }}</p>
 
         <q-btn
-          flat
+          unelevated
           :label="$t('common.actions.resend_email')"
           color="violet-normal"
-          class="q-mt-md resend-btn"
+          class="resend-btn"
           :loading="resending"
           @click="onResend"
         />
 
-        <div class="column items-center q-mt-sm">
-          <a href="#" class="login-link" @click.prevent="goToLogin">
+        <div class="column items-center q-mt-sm q-pt-md">
+          <a href="#" class="login-link q-py-md" @click.prevent="goToLogin">
             {{ $t("auth.remember_password") }}
             <strong>{{ $t("auth.do_login") }}</strong>
           </a>
-          <a href="#" class="login-link q-mt-xs" @click.prevent="goBack">
+          <a href="#" class="login-link q-mt-xs q-pt-md" @click.prevent="goBack">
             <q-icon name="mdi-arrow-left" size="12px" class="q-mr-xs" />
             {{ $t("auth.back_to_site") }}
           </a>
@@ -71,42 +70,51 @@
 </template>
 
 <script setup>
-import { ref, useTemplateRef } from "vue";
+import { ref, nextTick } from "vue";
 import { useRouter, useRoute } from "vue-router";
 import { useQuasar } from "quasar";
-import { useInputRules } from "src/composables/useInputRules";
 import { useSubmitHandler } from "src/composables/useSubmitHandler";
 import { verifyCode, forgotPassword } from "src/api/auth";
 
 import Logo from "src/assets/logo_serprati.svg";
-import DefaultInput from "src/components/defaults/DefaultInput.vue";
 import UserTypeBadge from "./components/UserTypeBadge.vue";
 
 const router = useRouter();
 const route  = useRoute();
 const $q     = useQuasar();
-const { inputRules } = useInputRules();
 
-const email  = route.query.email ?? "";
-const tipo   = route.query.tipo  ?? "administrador";
+const email = route.query.email ?? "";
+const tipo  = route.query.tipo  ?? "administrador";
 
-const formRef  = useTemplateRef("formRef");
-const resending = ref(false);
+const codeInputRef = ref(null);
+const inputFocused = ref(false);
+const resending    = ref(false);
+const form         = ref({ codigo: "" });
 
-const {
-  loading,
-  validationErrors,
-  execute: submitForm,
-} = useSubmitHandler({
+const { loading, execute: submitForm } = useSubmitHandler({
   onSuccess: () =>
     router.push({ name: "ResetPasswordPage", query: { email, tipo, codigo: form.value.codigo } }),
-  formRef,
+  onError: () => {
+    $q.notify({ type: "negative", message: "Código inválido. Verifique e tente novamente." });
+    form.value.codigo = "";
+    nextTick(() => codeInputRef.value?.focus());
+  },
 });
 
-const form = ref({ codigo: null });
+const focusCodeInput = () => codeInputRef.value?.focus();
 
-const onSubmit = async () => {
-  await submitForm(() => verifyCode(email, form.value.codigo));
+const filterNonNumeric = (e) => {
+  const allowed = ["Backspace", "Delete", "Tab", "ArrowLeft", "ArrowRight"];
+  if (!allowed.includes(e.key) && !/^\d$/.test(e.key)) {
+    e.preventDefault();
+  }
+};
+
+const onCodeInput = async () => {
+  form.value.codigo = (form.value.codigo ?? "").replace(/\D/g, "").slice(0, 6);
+  if (form.value.codigo.length === 6) {
+    await submitForm(() => verifyCode(email, form.value.codigo));
+  }
 };
 
 const onResend = async () => {
@@ -171,33 +179,90 @@ const goBack    = () => router.push({ name: "LoginPage" });
   color: map.get($colors, "violet-normal");
   font-size: 18px;
   font-weight: 700;
-  margin: 0 0 16px;
+  margin: 0 0 24px;
   text-align: center;
 }
 
-.email-box {
+.code-input-container {
+  position: relative;
+  cursor: text;
+  width: 100%;
   display: flex;
-  align-items: center;
-  background: map.get($colors, "violet-light");
-  border-radius: 8px;
-  padding: 8px 16px;
+  justify-content: center;
+}
+
+.code-hidden-input {
+  position: absolute;
+  opacity: 0;
+  pointer-events: none;
+  width: 1px;
+  height: 1px;
+  top: 0;
+  left: 0;
+}
+
+.code-slots {
+  display: flex;
+  gap: 16px;
+}
+
+.code-slot {
+  width: 40px;
+  height: 52px;
+  display: flex;
+  align-items: flex-end;
+  justify-content: center;
+  padding-bottom: 6px;
+  border-bottom: 2.5px solid map.get($colors, "violet-normal");
+  position: relative;
+  transition: border-color 0.15s;
+
+  &--filled {
+    border-bottom-color: map.get($colors, "violet-normal-hover");
+  }
+
+  &--active {
+    border-bottom-color: map.get($colors, "violet-normal-active");
+
+    &::after {
+      content: "";
+      position: absolute;
+      bottom: 6px;
+      width: 1.5px;
+      height: 20px;
+      background: map.get($colors, "violet-normal");
+      animation: blink-cursor 1s step-end infinite;
+    }
+  }
+}
+
+.code-char {
+  font-size: 22px;
+  font-weight: 700;
+  color: map.get($colors, "violet-normal");
+  line-height: 1;
+}
+
+@keyframes blink-cursor {
+  0%, 100% { opacity: 1; }
+  50%       { opacity: 0; }
+}
+
+.email-text {
   color: map.get($colors, "violet-normal");
   font-size: 14px;
   font-weight: 500;
+  text-align: center;
+  margin: 0;
 }
 
-.login-btn {
+.resend-btn {
+  font-size: 13px;
   height: 44px;
-  font-size: 15px;
   font-weight: 600;
   letter-spacing: 0.5px;
 }
 
-.resend-btn {
-  font-size: 13px;
-  text-decoration: underline;
-}
-
 .login-link {
   color: map.get($colors, "violet-normal");
   font-size: 13px;

+ 10 - 16
src/pages/login/VerifyEmailPage.vue

@@ -13,13 +13,10 @@
           {{ $t("auth.verify_email_description") }}
         </p>
 
-        <div class="email-box q-mb-lg">
-          <q-icon name="mdi-email-outline" size="16px" class="q-mr-xs" />
-          <span>{{ email }}</span>
-        </div>
+        <p class="email-text q-mb-lg">{{ email }}</p>
 
         <q-btn
-          class="full-width login-btn"
+          class="login-btn q-my-lg"
           color="violet-normal"
           :label="$t('auth.enter_code')"
           unelevated
@@ -114,33 +111,30 @@ const goBack = () => router.push({ name: "LoginPage" });
 }
 
 .login-description {
-  color: map.get($colors, "text-2");
+  color: map.get($colors, "violet-normal");
   font-size: 13px;
   text-align: center;
   margin: 0 0 16px;
 }
 
-.email-box {
-  display: flex;
-  align-items: center;
-  background: map.get($colors, "violet-light");
-  border-radius: 8px;
-  padding: 8px 16px;
+.email-text {
   color: map.get($colors, "violet-normal");
   font-size: 14px;
   font-weight: 500;
+  text-align: center;
+  margin: 0;
 }
 
 .login-btn {
-  height: 44px;
-  font-size: 15px;
+  height: 36px;
+  font-size: 13px;
   font-weight: 600;
-  width: 100%;
+  padding: 0 32px;
   letter-spacing: 0.5px;
 }
 
 .login-hint {
-  color: map.get($colors, "text-2");
+  color: map.get($colors, "violet-normal");
   font-size: 12px;
   text-align: center;
   margin: 0;

+ 3 - 3
src/pages/login/components/UserTypeBadge.vue

@@ -28,13 +28,13 @@ const tipoData = computed(() => tipoMap[props.tipo] ?? tipoMap.administrador);
 @use "src/css/quasar.variables.scss" as *;
 
 .badge-pill {
-  background: map.get($colors, "violet-light");
-  border: 1.5px solid map.get($colors, "violet-light-active");
+  background: map.get($colors, "violet-normal");
   border-radius: 999px;
-  color: map.get($colors, "violet-normal");
+  color: white;
 }
 .badge-label {
   font-size: 13px;
   font-weight: 600;
+  color: white;
 }
 </style>