瀏覽代碼

feat: adiciona esqueci senha em franqueador

ebagabee 1 月之前
父節點
當前提交
f120ded467

+ 21 - 0
src/api/auth.js

@@ -0,0 +1,21 @@
+import api from "src/api";
+
+export const forgotPassword = async (email) => {
+  const { data } = await api.post("/forgot-password", { email });
+  return data;
+};
+
+export const verifyPasswordCode = async (email, code) => {
+  const { data } = await api.post("/forgot-password/verify-code", { email, code });
+  return data;
+};
+
+export const resetPassword = async (email, code, password, passwordConfirmation) => {
+  const { data } = await api.post("/reset-password", {
+    email,
+    code,
+    password,
+    password_confirmation: passwordConfirmation,
+  });
+  return data;
+};

+ 44 - 49
src/pages/ForgotMyPasswordPage.vue

@@ -16,69 +16,64 @@
           name="mdi-arrow-left"
           size="sm"
           color="primary"
-          @click="$router.back()"
+          @click="currentStep === 'resend-email' ? currentStep = 'email-data' : $router.back()"
         />
         <span class="text-h6 text-weight-regular">Esqueci minha senha</span>
       </div>
 
-      <div class="column items-center full-width">
-        <q-img
-          :src="Logo"
-          style="max-width: 379px; max-height: 106px; height: 100%; width: 100%"
-        />
-
-        <div
-          class="items-center full-width q-px-lg q-mt-xl"
-          style="display: flex; gap: 12px"
-        >
-          <div class="bg-primary" style="height: 1px; flex: 1"></div>
-          <q-icon name="mdi-chevron-down" size="lg" color="warning" />
-          <div class="bg-primary" style="height: 1px; flex: 1"></div>
-        </div>
-
-        <div class="text-body1 q-my-lg text-primary" style="font-weight: 400">
-          Informe o seu e-mail para redefinir sua senha
-        </div>
-
-        <q-form class="full-width q-px-lg column q-gutter-y-lg q-mt-xs">
-          <DefaultInput
-            v-model="email"
-            type="email"
-            lazy-rules
-            :label="$t('common.terms.email')"
-            :rules="[inputRules.required]"
-          />
+      <EmailDataStep
+        v-if="currentStep === 'email-data'"
+        :loading
+        :email-error="emailError"
+        @submit="handleSubmit"
+        @clear-error="emailError = ''"
+      />
 
-          <div>
-            <q-btn
-              class="full-width q-mt-md"
-              color="primary-2"
-              label="Continuar"
-              size="md"
-              padding="sm"
-              type="submit"
-              :loading
-            >
-              <template #loading>
-                <q-spinner />
-              </template>
-            </q-btn>
-          </div>
-        </q-form>
-      </div>
+      <ResendEmailStep
+        v-else-if="currentStep === 'resend-email'"
+        :email
+        :loading
+        @resend="handleResend"
+      />
     </div>
   </q-page>
 </template>
 
 <script setup>
-import Logo from "src/assets/images/logo.svg";
-import DefaultInput from "src/components/defaults/DefaultInput.vue";
-import { useInputRules } from "src/composables/useInputRules";
 import { ref } from "vue";
+import EmailDataStep from "./forgot-password/EmailDataStep.vue";
+import ResendEmailStep from "./forgot-password/ResendEmailStep.vue";
+import { forgotPassword } from "src/api/auth";
 
+const currentStep = ref("email-data");
 const email = ref("");
+const loading = ref(false);
+const emailError = ref("");
+
+async function handleSubmit(submittedEmail) {
+  loading.value = true;
+  emailError.value = "";
+  try {
+    await forgotPassword(submittedEmail);
+    email.value = submittedEmail;
+    currentStep.value = "resend-email";
+  } catch (err) {
+    if (err.response?.status === 422) {
+      emailError.value = err.response.data.message;
+    }
+  } finally {
+    loading.value = false;
+  }
+}
 
-const { inputRules } = useInputRules();
+async function handleResend() {
+  loading.value = true;
+  try {
+    await forgotPassword(email.value);
+  } finally {
+    loading.value = false;
+  }
+}
 </script>
 
 <style lang="scss" scoped>

+ 96 - 0
src/pages/RecoveryPasswordPage.vue

@@ -0,0 +1,96 @@
+<template>
+  <q-page class="column justify-center items-center login-page">
+    <div
+      flat
+      class="column justify-center items-center full-width z-top bg-background q-py-xl"
+      style="max-width: 659px; border-radius: 15px;"
+    >
+      <div class="flex full-width q-gutter-x-sm items-center q-mb-xl">
+        <q-icon
+          name="mdi-arrow-left"
+          size="sm"
+          color="primary"
+          @click="handleBack"
+        />
+        <span class="text-h6 text-weight-regular">Recuperar senha</span>
+      </div>
+
+      <VerifyCodeStep
+        v-if="currentStep === 'verify-code'"
+        :loading
+        @submit="handleVerify"
+        @resend="handleResend"
+      />
+
+      <NewPasswordStep
+        v-else-if="currentStep === 'new-password'"
+        :loading
+        @submit="handleReset"
+      />
+    </div>
+  </q-page>
+</template>
+
+<script setup>
+import { ref } from "vue";
+import { useRoute, useRouter } from "vue-router";
+import VerifyCodeStep from "./forgot-password/VerifyCodeStep.vue";
+import NewPasswordStep from "./forgot-password/NewPasswordStep.vue";
+import { forgotPassword, verifyPasswordCode, resetPassword } from "src/api/auth";
+
+const route = useRoute();
+const router = useRouter();
+
+const email = route.query.email ?? "";
+const currentStep = ref("verify-code");
+const verifiedCode = ref("");
+const loading = ref(false);
+
+function handleBack() {
+  if (currentStep.value === "new-password") {
+    currentStep.value = "verify-code";
+  } else {
+    router.push("/forgot-my-password");
+  }
+}
+
+async function handleVerify(code) {
+  loading.value = true;
+  try {
+    await verifyPasswordCode(email, code);
+    verifiedCode.value = code;
+    await new Promise((resolve) => setTimeout(resolve, 3000));
+    currentStep.value = "new-password";
+  } finally {
+    loading.value = false;
+  }
+}
+
+async function handleResend() {
+  loading.value = true;
+  try {
+    await forgotPassword(email);
+  } finally {
+    loading.value = false;
+  }
+}
+
+async function handleReset({ password, passwordConfirmation }) {
+  loading.value = true;
+  try {
+    await resetPassword(email, verifiedCode.value, password, passwordConfirmation);
+    router.push("/login");
+  } finally {
+    loading.value = false;
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.login-page {
+  background-image: url("background.png");
+  background-size: cover;
+  background-repeat: no-repeat;
+  background-position: center;
+}
+</style>

+ 67 - 0
src/pages/forgot-password/EmailDataStep.vue

@@ -0,0 +1,67 @@
+<template>
+  <div class="column items-center full-width">
+    <q-img
+      :src="Logo"
+      style="max-width: 379px; max-height: 106px; height: 100%; width: 100%"
+    />
+
+    <div
+      class="items-center full-width q-px-lg q-mt-xl"
+      style="display: flex; gap: 12px"
+    >
+      <div class="bg-primary" style="height: 1px; flex: 1"></div>
+      <q-icon name="mdi-chevron-down" size="lg" color="warning" />
+      <div class="bg-primary" style="height: 1px; flex: 1"></div>
+    </div>
+
+    <div class="text-body1 q-my-lg text-primary" style="font-weight: 400">
+      Informe o seu e-mail para redefinir sua senha
+    </div>
+
+    <q-form class="full-width q-px-lg column q-gutter-y-lg q-mt-xs" @submit.prevent="emit('submit', email)">
+      <DefaultInput
+        v-model="email"
+        type="email"
+        lazy-rules
+        :label="$t('common.terms.email')"
+        :rules="[inputRules.required]"
+        :error="!!emailError"
+        :error-message="emailError"
+        @update:model-value="emit('clear-error')"
+      />
+
+      <div>
+        <q-btn
+          class="full-width q-mt-md"
+          color="primary-2"
+          label="Continuar"
+          size="md"
+          padding="sm"
+          type="submit"
+          :loading
+        >
+          <template #loading>
+            <q-spinner />
+          </template>
+        </q-btn>
+      </div>
+    </q-form>
+  </div>
+</template>
+
+<script setup>
+import Logo from "src/assets/images/logo.svg";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import { useInputRules } from "src/composables/useInputRules";
+import { ref } from "vue";
+
+defineProps({
+  loading: Boolean,
+  emailError: { type: String, default: "" },
+});
+
+const emit = defineEmits(["submit", "clear-error"]);
+
+const email = ref("");
+const { inputRules } = useInputRules();
+</script>

+ 71 - 0
src/pages/forgot-password/NewPasswordStep.vue

@@ -0,0 +1,71 @@
+<template>
+  <div class="column items-center full-width">
+    <q-img
+      :src="Logo"
+      style="max-width: 379px; max-height: 106px; height: 100%; width: 100%"
+    />
+
+    <div class="text-h5 text-weight-regular q-mt-lg text-primary text-center">
+      Definir nova senha
+    </div>
+
+    <div
+      class="items-center full-width q-px-lg q-mt-lg"
+      style="display: flex; gap: 12px"
+    >
+      <div class="bg-primary" style="height: 1px; flex: 1"></div>
+      <q-icon name="mdi-chevron-down" size="lg" color="warning" />
+      <div class="bg-primary" style="height: 1px; flex: 1"></div>
+    </div>
+
+    <q-form
+      class="full-width q-px-lg column q-gutter-y-md q-mt-md"
+      @submit.prevent="emit('submit', { password, passwordConfirmation })"
+    >
+      <DefaultPasswordInput
+        v-model="password"
+        label="Nova senha"
+        lazy-rules
+        :rules="[inputRules.required, inputRules.min(8)]"
+      />
+
+      <DefaultPasswordInput
+        v-model="passwordConfirmation"
+        label="Confirmar nova senha"
+        lazy-rules
+        :rules="[inputRules.required, (v) => v === password || 'As senhas não conferem']"
+      />
+
+      <q-btn
+        class="full-width q-mt-sm"
+        color="primary-2"
+        label="Confirmar"
+        size="md"
+        padding="sm"
+        type="submit"
+        :loading
+      >
+        <template #loading>
+          <q-spinner />
+        </template>
+      </q-btn>
+    </q-form>
+  </div>
+</template>
+
+<script setup>
+import Logo from "src/assets/images/logo.svg";
+import DefaultPasswordInput from "src/components/defaults/DefaultPasswordInput.vue";
+import { useInputRules } from "src/composables/useInputRules";
+import { ref } from "vue";
+
+defineProps({
+  loading: Boolean,
+});
+
+const emit = defineEmits(["submit"]);
+
+const password = ref("");
+const passwordConfirmation = ref("");
+const { inputRules } = useInputRules();
+</script>

+ 67 - 0
src/pages/forgot-password/ResendEmailStep.vue

@@ -0,0 +1,67 @@
+<template>
+  <div class="column items-center full-width">
+    <q-img
+      :src="Logo"
+      style="max-width: 379px; max-height: 106px; height: 100%; width: 100%"
+    />
+    <div class="text-h4 text-weight-regular q-mt-lg text-primary">
+      Verifique seu e-mail
+    </div>
+
+    <div
+      class="items-center full-width q-px-lg q-mt-xl"
+      style="display: flex; gap: 12px"
+    >
+      <div class="bg-primary" style="height: 1px; flex: 1"></div>
+      <q-icon name="mdi-chevron-down" size="lg" color="warning" />
+      <div class="bg-primary" style="height: 1px; flex: 1"></div>
+    </div>
+
+    <div
+      class="text-body2 text-center text-primary q-px-lg"
+      style="font-weight: 400"
+    >
+      Enviamos um e-mail com um link para redefinir sua senha<br />
+      Por favor confira sua caixa de entrada e clique no link para prosseguir
+    </div>
+
+    <div class="text-body1 text-weight-medium text-primary q-mt-md">
+      {{ email }}
+    </div>
+
+    <div
+      class="text-body2 text-center text-primary q-mt-sm q-px-lg"
+      style="font-weight: 400"
+    >
+      Não achou o e-mail? Verifique a caixa de spam ou tente novamente
+    </div>
+
+    <div class="full-width q-px-lg q-mt-lg">
+      <q-btn
+        class="full-width"
+        color="primary-2"
+        size="md"
+        padding="sm"
+        :loading
+        @click="emit('resend')"
+      >
+        <q-icon name="mdi-refresh" class="q-mr-sm" />
+        Reenviar E-mail
+        <template #loading>
+          <q-spinner />
+        </template>
+      </q-btn>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import Logo from "src/assets/images/logo.svg";
+
+defineProps({
+  email: { type: String, required: true },
+  loading: Boolean,
+});
+
+const emit = defineEmits(["resend"]);
+</script>

+ 138 - 0
src/pages/forgot-password/VerifyCodeStep.vue

@@ -0,0 +1,138 @@
+<template>
+  <div class="column items-center full-width">
+    <q-img
+      :src="Logo"
+      style="max-width: 379px; max-height: 106px; height: 100%; width: 100%"
+    />
+
+    <div class="text-h5 text-weight-regular q-mt-lg text-primary text-center">
+      Digite o código de verificação
+    </div>
+
+    <div
+      class="items-center full-width q-px-lg q-mt-lg"
+      style="display: flex; gap: 12px"
+    >
+      <div class="bg-primary" style="height: 1px; flex: 1"></div>
+      <q-icon name="mdi-chevron-down" size="lg" color="warning" />
+      <div class="bg-primary" style="height: 1px; flex: 1"></div>
+    </div>
+
+    <div
+      class="text-body2 text-center text-primary q-px-lg q-mt-md"
+      style="font-weight: 400"
+    >
+      Enviamos um código de 6 dígitos para seu e-mail<br />
+      Digite o código para verificar
+    </div>
+
+    <div class="row q-gutter-x-sm q-mt-xl justify-center">
+      <input
+        v-for="(_, index) in 6"
+        :key="index"
+        :ref="(el) => (inputs[index] = el)"
+        v-model="digits[index]"
+        type="text"
+        inputmode="numeric"
+        maxlength="1"
+        class="otp-input text-primary text-h5 text-center text-weight-medium"
+        :disabled="loading"
+        @input="handleInput(index)"
+        @keydown="handleKeydown(index, $event)"
+        @paste="handlePaste"
+      />
+    </div>
+
+    <div class="full-width q-px-lg q-mt-xl">
+      <q-btn
+        class="full-width"
+        color="primary-2"
+        size="md"
+        padding="sm"
+        :loading
+        @click="emit('resend')"
+      >
+        <q-icon name="mdi-refresh" class="q-mr-sm" />
+        Reenviar E-mail
+        <template #loading>
+          <q-spinner />
+        </template>
+      </q-btn>
+    </div>
+
+    <div class="q-mt-md text-body2 text-primary">
+      Lembrou sua senha?
+      <router-link to="/login" class="text-warning text-weight-medium" style="text-decoration: none">
+        Faça seu Login
+      </router-link>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import Logo from "src/assets/images/logo.svg";
+import { ref } from "vue";
+
+defineProps({
+  loading: Boolean,
+});
+
+const emit = defineEmits(["submit", "resend"]);
+
+const digits = ref(["", "", "", "", "", ""]);
+const inputs = ref([]);
+
+function handleInput(index) {
+  const val = digits.value[index].replace(/\D/g, "").slice(-1);
+  digits.value[index] = val;
+
+  if (val && index < 5) {
+    inputs.value[index + 1]?.focus();
+  }
+
+  const code = digits.value.join("");
+  if (code.length === 6) {
+    emit("submit", code);
+  }
+}
+
+function handleKeydown(index, event) {
+  if (event.key === "Backspace" && !digits.value[index] && index > 0) {
+    inputs.value[index - 1]?.focus();
+  }
+}
+
+function handlePaste(event) {
+  event.preventDefault();
+  const text = event.clipboardData.getData("text").replace(/\D/g, "").slice(0, 6);
+  text.split("").forEach((char, i) => {
+    digits.value[i] = char;
+  });
+  const nextIndex = Math.min(text.length, 5);
+  inputs.value[nextIndex]?.focus();
+
+  if (text.length === 6) {
+    emit("submit", text);
+  }
+}
+</script>
+
+<style scoped>
+.otp-input {
+  width: 44px;
+  height: 52px;
+  border: none;
+  border-bottom: 2px solid #1a5c38;
+  background: transparent;
+  outline: none;
+  font-size: 1.5rem;
+}
+
+.otp-input:focus {
+  border-bottom-color: #f5a623;
+}
+
+.otp-input:disabled {
+  opacity: 0.5;
+}
+</style>

+ 3 - 1
src/router/index.js

@@ -43,7 +43,9 @@ export default defineRouter(function (/* { store, ssrContext } */) {
         await useAuth().refresh();
       } catch {
         refreshed = true;
-        return next({ name: "LoginPage" });
+        if (!to.meta.public) {
+          return next({ name: "LoginPage" });
+        }
       }
     }
     if (userStore().accessToken) {

+ 18 - 3
src/router/routes.js

@@ -79,13 +79,28 @@ const routes = [
   {
     path: "/forgot-my-password",
     component: () => import("layouts/LoginLayout.vue"),
+    meta: { public: true },
     children: [
       {
         path: "",
         name: "ForgotMyPasswordPage",
-        component: () => import("pages/ForgotMyPasswordPage.vue")
-      }
-    ]
+        component: () => import("pages/ForgotMyPasswordPage.vue"),
+        meta: { public: true },
+      },
+    ],
+  },
+  {
+    path: "/recovery-password",
+    component: () => import("layouts/LoginLayout.vue"),
+    meta: { public: true },
+    children: [
+      {
+        path: "",
+        name: "RecoveryPasswordPage",
+        component: () => import("pages/RecoveryPasswordPage.vue"),
+        meta: { public: true },
+      },
+    ],
   },
 
   // Always leave this as last one,