Преглед на файлове

feat: :sparkles: feat (recuperar senha) criado fluxo de recuperar senha no app

foi criado o fluxo de recuperar a senha, enviando codigo para o email

fase:dev | origin:escopo
Gustavo Zanatta преди 2 месеца
родител
ревизия
edf0f4090e

+ 3 - 0
src/i18n/locales/en.json

@@ -252,6 +252,9 @@
     "password_hint": "Minimum 6 characters with uppercase, lowercase and numbers",
     "confirm": "Confirm",
     "back_to_site": "Back to Site",
+    "invalid_code": "Invalid code. Please check and try again.",
+    "code_resent": "Code resent successfully!",
+    "resend_error": "Could not resend the code. Please try again.",
     "wrong_type": "User found, but the selected type is incorrect. Please select the correct login type."
   },
   "business": {

+ 4 - 1
src/i18n/locales/es.json

@@ -233,7 +233,7 @@
       "associado": "Asociado",
       "parceiro": "Socio"
     },
-    "forgot_password": "¿Olvidé mi contraseña?",
+    "forgot_password": "¿Olvidé tu contraseña?",
     "forgot_password_title": "Ingrese su correo para restablecer su contraseña",
     "forgot_password_description": "Le enviaremos un código de verificación a su correo.",
     "continue": "Continuar",
@@ -252,6 +252,9 @@
     "password_hint": "Mínimo 6 caracteres con mayúsculas, minúsculas y números",
     "confirm": "Confirmar",
     "back_to_site": "Volver al Sitio",
+    "invalid_code": "Código inválido. Verifique e intente de nuevo.",
+    "code_resent": "¡Código reenviado con éxito!",
+    "resend_error": "No se pudo reenviar el código. Inténtelo de nuevo.",
     "wrong_type": "Usuario encontrado, pero el tipo seleccionado es incorrecto. Seleccione el tipo correcto."
   },
   "business": {

+ 4 - 1
src/i18n/locales/pt.json

@@ -233,7 +233,7 @@
       "associado": "Associado",
       "parceiro": "Parceiro"
     },
-    "forgot_password": "Esqueci a senha?",
+    "forgot_password": "Esqueceu a senha?",
     "forgot_password_title": "Informe seu e-mail para redefinir sua senha",
     "forgot_password_description": "Enviaremos um código de verificação para o seu e-mail.",
     "continue": "Continuar",
@@ -252,6 +252,9 @@
     "password_hint": "Mínimo de 6 caracteres com letras maiúsculas, minúsculas e números",
     "confirm": "Confirmar",
     "back_to_site": "Voltar ao Site",
+    "invalid_code": "Código inválido. Verifique e tente novamente.",
+    "code_resent": "Código reenviado com sucesso!",
+    "resend_error": "Não foi possível reenviar o código. Tente novamente.",
     "wrong_type": "Usuário encontrado, mas o tipo selecionado está incorreto. Selecione o login correto."
   },
   "business": {

+ 185 - 0
src/pages/login/ForgotPasswordPage.vue

@@ -0,0 +1,185 @@
+<template>
+  <q-page class="login-page column items-center justify-center">
+    <div class="login-overlay" />
+
+    <div class="login-card column items-center">
+      <div class="column items-center fields-card">
+        <q-img :src="Logo" class="login-logo q-mb-lg" />
+
+        <UserTypeBadge tipo="associado" />
+
+        <p class="login-title">{{ $t("auth.forgot_password_title") }}</p>
+
+        <q-form
+          ref="formRef"
+          class="full-width"
+          autocomplete="off"
+          @submit="onSubmit"
+        >
+          <DefaultInput
+            v-model="form.email"
+            v-model:error="validationErrors.email"
+            type="email"
+            autofocus
+            color="violet-normal"
+            :label="$t('common.terms.email')"
+            :rules="[inputRules.required, inputRules.email]"
+          >
+            <template #append>
+              <q-icon name="mdi-account-outline" color="grey-5" />
+            </template>
+          </DefaultInput>
+
+          <q-btn
+            class="full-width q-mt-sm login-btn"
+            color="violet-normal"
+            :label="$t('auth.continue')"
+            type="submit"
+            unelevated
+            :loading
+          >
+            <template #loading>
+              <q-spinner />
+            </template>
+          </q-btn>
+        </q-form>
+
+        <a href="#" class="login-link q-mt-md" @click.prevent="goBack">
+          <q-icon name="mdi-arrow-left" size="12px" class="q-mr-xs" />
+          {{ $t("auth.back_to_site") }}
+        </a>
+      </div>
+    </div>
+  </q-page>
+</template>
+
+<script setup>
+import { ref, useTemplateRef } from "vue";
+import { useRouter } from "vue-router";
+import { useInputRules } from "src/composables/useInputRules";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+import { forgotPassword } from "src/api/auth";
+
+import Logo from "src/assets/logo_serprati.svg";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import UserTypeBadge from "./component/UserTypeBadge.vue";
+
+const router = useRouter();
+const { inputRules } = useInputRules();
+
+const formRef = useTemplateRef("formRef");
+const form = ref({ email: null });
+
+const {
+  loading,
+  validationErrors,
+  execute: submitForm,
+} = useSubmitHandler({
+  onSuccess: () =>
+    router.push({
+      name: "VerifyEmailPage",
+      query: { email: form.value.email },
+    }),
+  formRef,
+});
+
+const onSubmit = async () => {
+  await submitForm(() => forgotPassword(form.value.email, "associado"));
+};
+
+const goBack = () => router.push({ name: "LoginPage" });
+</script>
+
+<style lang="scss" scoped>
+@use "sass:map";
+@use "src/css/quasar.variables.scss" as *;
+
+.login-page {
+  min-height: 100dvh;
+  background-image: url("src/assets/pessoas_fundo.jpg");
+  background-size: cover;
+  background-position: center;
+  position: relative;
+}
+
+.login-overlay {
+  position: absolute;
+  inset: 0;
+  background: rgba(74, 20, 140, 0.48);
+  z-index: 0;
+}
+
+.login-card {
+  z-index: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: calc(100% - 32px);
+  min-height: calc(100dvh - 32px);
+  background: rgba(255, 255, 255, 0.747);
+  border-radius: 16px;
+  box-shadow: 0 4px 32px rgba(0, 0, 0, 0.15);
+  padding-top: env(safe-area-inset-top);
+  padding-bottom: env(safe-area-inset-bottom);
+}
+
+.fields-card {
+  width: 100%;
+  max-width: 400px;
+  padding: 32px;
+  border-radius: 12px;
+
+  :deep(.q-field__native),
+  :deep(.q-field__input) {
+    color: $color-text !important;
+  }
+
+  :deep(.q-field__control) {
+    background: $surface !important;
+  }
+
+  :deep(.q-field__label) {
+    color: $color-text-2;
+  }
+
+  :deep(.q-field--focused .q-field__label) {
+    color: $violet-normal;
+  }
+
+  :deep(.q-field--float:not(.q-field--focused) .q-field__label) {
+    color: $neutral-dark-active;
+  }
+}
+
+.login-logo {
+  width: 280px;
+  max-width: 100%;
+}
+
+.login-title {
+  color: map.get($colors, "violet-normal");
+  font-size: 18px;
+  font-weight: 700;
+  margin: 0 0 20px;
+  text-align: center;
+}
+
+.login-btn {
+  height: 44px;
+  font-size: 15px;
+  font-weight: 600;
+  letter-spacing: 0.5px;
+}
+
+.login-link {
+  color: map.get($colors, "violet-normal");
+  font-size: 13px;
+  text-decoration: none;
+  cursor: pointer;
+
+  &:hover {
+    color: map.get($colors, "violet-normal-hover");
+    text-decoration: underline;
+  }
+}
+</style>

+ 20 - 0
src/pages/login/LoginPage.vue

@@ -60,6 +60,14 @@
           </q-btn>
         </q-form>
 
+        <a
+          href="#"
+          class="forgot-link q-mt-md"
+          @click.prevent="router.push({ name: 'ForgotPasswordPage' })"
+        >
+          {{ $t("auth.forgot_password") }}
+        </a>
+
         <q-btn
           flat
           no-caps
@@ -212,6 +220,18 @@ onBeforeMount(() => {
   letter-spacing: 0.5px;
 }
 
+.forgot-link {
+  color: map.get($colors, "violet-normal");
+  font-size: 13px;
+  text-decoration: none;
+  cursor: pointer;
+
+  &:hover {
+    color: map.get($colors, "violet-normal-hover");
+    text-decoration: underline;
+  }
+}
+
 .privacy-link {
   font-size: 12px;
   color: rgba(74, 20, 140, 0.65);

+ 194 - 0
src/pages/login/ResetPasswordPage.vue

@@ -0,0 +1,194 @@
+<template>
+  <q-page class="login-page column items-center justify-center">
+    <div class="login-overlay" />
+
+    <div class="login-card column items-center">
+      <div class="column items-center fields-card">
+        <q-img :src="Logo" class="login-logo q-mb-lg" />
+
+        <UserTypeBadge tipo="associado" />
+
+        <p class="login-title">{{ $t("auth.new_password_title") }}</p>
+
+        <q-form
+          ref="formRef"
+          class="full-width"
+          autocomplete="off"
+          @submit="onSubmit"
+        >
+          <DefaultPasswordInput
+            v-model="form.password"
+            v-model:error="validationErrors.password"
+            :label="$t('common.terms.password')"
+            :rules="[inputRules.required, inputRules.password]"
+            :hint="$t('auth.password_hint')"
+          />
+          <DefaultPasswordInput
+            v-model="form.password_confirmation"
+            v-model:error="validationErrors.password_confirmation"
+            :label="$t('auth.confirm_password')"
+            :rules="[inputRules.required, (v) => v === form.password || $t('validation.rules.same_password')]"
+          />
+
+          <q-btn
+            class="full-width q-mt-sm login-btn"
+            color="violet-normal"
+            :label="$t('auth.confirm')"
+            type="submit"
+            unelevated
+            :loading
+          >
+            <template #loading><q-spinner /></template>
+          </q-btn>
+        </q-form>
+
+        <a href="#" class="login-link q-mt-md" @click.prevent="goBack">
+          <q-icon name="mdi-arrow-left" size="12px" class="q-mr-xs" />
+          {{ $t("auth.back_to_site") }}
+        </a>
+      </div>
+    </div>
+  </q-page>
+</template>
+
+<script setup>
+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 { resetPassword } from "src/api/auth";
+
+import Logo from "src/assets/logo_serprati.svg";
+import DefaultPasswordInput from "src/components/defaults/DefaultPasswordInput.vue";
+import UserTypeBadge from "./component/UserTypeBadge.vue";
+
+const router = useRouter();
+const route  = useRoute();
+const { inputRules } = useInputRules();
+const { setAuthDataFromPayload } = useAuth();
+
+const email  = route.query.email  ?? "";
+const codigo = route.query.codigo ?? "";
+
+const formRef = useTemplateRef("formRef");
+
+const {
+  loading,
+  validationErrors,
+  execute: submitForm,
+} = useSubmitHandler({
+  onSuccess: async (response) => {
+    await setAuthDataFromPayload(response.data.payload);
+    router.push({ name: "CarteirinhaPage" });
+  },
+  formRef,
+});
+
+const form = ref({
+  password: null,
+  password_confirmation: null,
+});
+
+const onSubmit = async () => {
+  await submitForm(() =>
+    resetPassword(email, codigo, form.value.password, form.value.password_confirmation)
+  );
+};
+
+const goBack = () => router.push({ name: "LoginPage" });
+</script>
+
+<style lang="scss" scoped>
+@use "sass:map";
+@use "src/css/quasar.variables.scss" as *;
+
+.login-page {
+  min-height: 100dvh;
+  background-image: url("src/assets/pessoas_fundo.jpg");
+  background-size: cover;
+  background-position: center;
+  position: relative;
+}
+
+.login-overlay {
+  position: absolute;
+  inset: 0;
+  background: rgba(74, 20, 140, 0.48);
+  z-index: 0;
+}
+
+.login-card {
+  z-index: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: calc(100% - 32px);
+  min-height: calc(100dvh - 32px);
+  background: rgba(255, 255, 255, 0.747);
+  border-radius: 16px;
+  box-shadow: 0 4px 32px rgba(0, 0, 0, 0.15);
+  padding-top: env(safe-area-inset-top);
+  padding-bottom: env(safe-area-inset-bottom);
+}
+
+.fields-card {
+  width: 100%;
+  max-width: 400px;
+  padding: 32px;
+  border-radius: 12px;
+
+  :deep(.q-field__native),
+  :deep(.q-field__input) {
+    color: $color-text !important;
+  }
+
+  :deep(.q-field__control) {
+    background: $surface !important;
+  }
+
+  :deep(.q-field__label) {
+    color: $color-text-2;
+  }
+
+  :deep(.q-field--focused .q-field__label) {
+    color: $violet-normal;
+  }
+
+  :deep(.q-field--float:not(.q-field--focused) .q-field__label) {
+    color: $neutral-dark-active;
+  }
+}
+
+.login-logo {
+  width: 280px;
+  max-width: 100%;
+}
+
+.login-title {
+  color: map.get($colors, "violet-normal");
+  font-size: 18px;
+  font-weight: 700;
+  margin: 0 0 20px;
+  text-align: center;
+}
+
+.login-btn {
+  height: 44px;
+  font-size: 15px;
+  font-weight: 600;
+  letter-spacing: 0.5px;
+}
+
+.login-link {
+  color: map.get($colors, "violet-normal");
+  font-size: 13px;
+  text-decoration: none;
+  cursor: pointer;
+
+  &:hover {
+    color: map.get($colors, "violet-normal-hover");
+    text-decoration: underline;
+  }
+}
+</style>

+ 281 - 0
src/pages/login/VerifyCodePage.vue

@@ -0,0 +1,281 @@
+<template>
+  <q-page class="login-page column items-center justify-center">
+    <div class="login-overlay" />
+
+    <div class="login-card column items-center">
+      <div class="column items-center fields-card">
+        <q-img :src="Logo" class="login-logo q-mb-lg" />
+
+        <UserTypeBadge tipo="associado" />
+
+        <p class="login-title">{{ $t("auth.enter_code_title") }}</p>
+
+        <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>
+
+        <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
+          unelevated
+          :label="$t('common.actions.resend_email')"
+          color="violet-normal"
+          class="resend-btn"
+          :loading="resending"
+          @click="onResend"
+        />
+
+        <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 q-pt-md" @click.prevent="goBack">
+            <q-icon name="mdi-arrow-left" size="12px" class="q-mr-xs" />
+            {{ $t("auth.back_to_site") }}
+          </a>
+        </div>
+      </div>
+    </div>
+  </q-page>
+</template>
+
+<script setup>
+import { ref, nextTick } from "vue";
+import { useRouter, useRoute } from "vue-router";
+import { useQuasar } from "quasar";
+import { useI18n } from "vue-i18n";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+import { verifyCode, forgotPassword } from "src/api/auth";
+
+import Logo from "src/assets/logo_serprati.svg";
+import UserTypeBadge from "./component/UserTypeBadge.vue";
+
+const router = useRouter();
+const route  = useRoute();
+const $q     = useQuasar();
+const { t }  = useI18n();
+
+const email = route.query.email ?? "";
+
+const codeInputRef = ref(null);
+const inputFocused = ref(false);
+const resending    = ref(false);
+const form         = ref({ codigo: "" });
+
+const { loading, execute: submitForm } = useSubmitHandler({
+  onSuccess: () =>
+    router.push({ name: "ResetPasswordPage", query: { email, codigo: form.value.codigo } }),
+  onError: () => {
+    $q.notify({ type: "negative", message: t("auth.invalid_code") });
+    form.value.codigo = "";
+    nextTick(() => codeInputRef.value?.focus());
+  },
+});
+
+const focusCodeInput = () => codeInputRef.value?.focus();
+
+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 () => {
+  resending.value = true;
+  try {
+    await forgotPassword(email, "associado");
+    $q.notify({ type: "positive", message: t("auth.code_resent") });
+  } catch {
+    $q.notify({ type: "negative", message: t("auth.resend_error") });
+  } finally {
+    resending.value = false;
+  }
+};
+
+const goToLogin = () => router.push({ name: "LoginPage" });
+const goBack    = () => router.push({ name: "LoginPage" });
+</script>
+
+<style lang="scss" scoped>
+@use "sass:map";
+@use "src/css/quasar.variables.scss" as *;
+
+.login-page {
+  min-height: 100dvh;
+  background-image: url("src/assets/pessoas_fundo.jpg");
+  background-size: cover;
+  background-position: center;
+  position: relative;
+}
+
+.login-overlay {
+  position: absolute;
+  inset: 0;
+  background: rgba(74, 20, 140, 0.48);
+  z-index: 0;
+}
+
+.login-card {
+  z-index: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: calc(100% - 32px);
+  min-height: calc(100dvh - 32px);
+  background: rgba(255, 255, 255, 0.747);
+  border-radius: 16px;
+  box-shadow: 0 4px 32px rgba(0, 0, 0, 0.15);
+  padding-top: env(safe-area-inset-top);
+  padding-bottom: env(safe-area-inset-bottom);
+}
+
+.fields-card {
+  width: 100%;
+  max-width: 400px;
+  padding: 32px;
+  border-radius: 12px;
+}
+
+.login-logo {
+  width: 280px;
+  max-width: 100%;
+}
+
+.login-title {
+  color: map.get($colors, "violet-normal");
+  font-size: 18px;
+  font-weight: 700;
+  margin: 0 0 24px;
+  text-align: center;
+}
+
+.code-input-container {
+  position: relative;
+  cursor: text;
+  width: 100%;
+  display: flex;
+  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;
+}
+
+.resend-btn {
+  font-size: 13px;
+  height: 44px;
+  font-weight: 600;
+  letter-spacing: 0.5px;
+}
+
+.login-link {
+  color: map.get($colors, "violet-normal");
+  font-size: 13px;
+  text-decoration: none;
+  cursor: pointer;
+
+  &:hover {
+    color: map.get($colors, "violet-normal-hover");
+    text-decoration: underline;
+  }
+}
+</style>

+ 156 - 0
src/pages/login/VerifyEmailPage.vue

@@ -0,0 +1,156 @@
+<template>
+  <q-page class="login-page column items-center justify-center">
+    <div class="login-overlay" />
+
+    <div class="login-card column items-center">
+      <div class="column items-center fields-card">
+        <q-img :src="Logo" class="login-logo q-mb-lg" />
+
+        <UserTypeBadge tipo="associado" />
+
+        <p class="login-title">{{ $t("auth.verify_email_title") }}</p>
+        <p class="login-description">
+          {{ $t("auth.verify_email_description") }}
+        </p>
+
+        <p class="email-text q-mb-lg">{{ email }}</p>
+
+        <q-btn
+          class="login-btn q-my-lg"
+          color="violet-normal"
+          :label="$t('auth.enter_code')"
+          unelevated
+          @click="goToCode"
+        />
+
+        <p class="login-hint q-mt-md">
+          {{ $t("auth.no_email_hint") }}
+          <a href="#" class="login-link" @click.prevent="resend">
+            {{ $t("auth.check_spam") }}
+          </a>
+        </p>
+
+        <a href="#" class="login-link q-mt-sm" @click.prevent="goBack">
+          <q-icon name="mdi-arrow-left" size="12px" class="q-mr-xs" />
+          {{ $t("auth.back_to_site") }}
+        </a>
+      </div>
+    </div>
+  </q-page>
+</template>
+
+<script setup>
+import { useRouter, useRoute } from "vue-router";
+
+import Logo from "src/assets/logo_serprati.svg";
+import UserTypeBadge from "./component/UserTypeBadge.vue";
+
+const router = useRouter();
+const route  = useRoute();
+
+const email = route.query.email ?? "";
+
+const goToCode = () =>
+  router.push({ name: "VerifyCodePage", query: { email } });
+
+const resend = () =>
+  router.push({ name: "ForgotPasswordPage" });
+
+const goBack = () => router.push({ name: "LoginPage" });
+</script>
+
+<style lang="scss" scoped>
+@use "sass:map";
+@use "src/css/quasar.variables.scss" as *;
+
+.login-page {
+  min-height: 100dvh;
+  background-image: url("src/assets/pessoas_fundo.jpg");
+  background-size: cover;
+  background-position: center;
+  position: relative;
+}
+
+.login-overlay {
+  position: absolute;
+  inset: 0;
+  background: rgba(74, 20, 140, 0.48);
+  z-index: 0;
+}
+
+.login-card {
+  z-index: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: calc(100% - 32px);
+  min-height: calc(100dvh - 32px);
+  background: rgba(255, 255, 255, 0.747);
+  border-radius: 16px;
+  box-shadow: 0 4px 32px rgba(0, 0, 0, 0.15);
+  padding-top: env(safe-area-inset-top);
+  padding-bottom: env(safe-area-inset-bottom);
+}
+
+.fields-card {
+  width: 100%;
+  max-width: 400px;
+  padding: 32px;
+  border-radius: 12px;
+}
+
+.login-logo {
+  width: 280px;
+  max-width: 100%;
+}
+
+.login-title {
+  color: map.get($colors, "violet-normal");
+  font-size: 18px;
+  font-weight: 700;
+  margin: 0 0 8px;
+  text-align: center;
+}
+
+.login-description {
+  color: map.get($colors, "violet-normal");
+  font-size: 13px;
+  text-align: center;
+  margin: 0 0 16px;
+}
+
+.email-text {
+  color: map.get($colors, "violet-normal");
+  font-size: 14px;
+  font-weight: 500;
+  text-align: center;
+  margin: 0;
+}
+
+.login-btn {
+  height: 36px;
+  font-size: 13px;
+  font-weight: 600;
+  padding: 0 32px;
+  letter-spacing: 0.5px;
+}
+
+.login-hint {
+  color: map.get($colors, "violet-normal");
+  font-size: 12px;
+  text-align: center;
+  margin: 0;
+}
+
+.login-link {
+  color: map.get($colors, "violet-normal");
+  font-size: 13px;
+  text-decoration: none;
+  cursor: pointer;
+
+  &:hover {
+    color: map.get($colors, "violet-normal-hover");
+    text-decoration: underline;
+  }
+}
+</style>

+ 40 - 0
src/pages/login/component/UserTypeBadge.vue

@@ -0,0 +1,40 @@
+<template>
+  <div class="user-type-badge row items-center justify-center q-mb-lg">
+    <div class="badge-pill row items-center q-px-lg q-py-sm">
+      <q-icon :name="tipoData.icon" size="20px" class="q-mr-sm" />
+      <span class="badge-label">{{ $t(tipoData.label) }}</span>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed } from "vue";
+
+const props = defineProps({
+  tipo: { type: String, required: true },
+});
+
+const tipoMap = {
+  administrador: { icon: "mdi-shield-account-outline", label: "auth.type.administrador" },
+  associado:     { icon: "mdi-account-outline",        label: "auth.type.associado" },
+  parceiro:      { icon: "mdi-handshake-outline",       label: "auth.type.parceiro" },
+};
+
+const tipoData = computed(() => tipoMap[props.tipo] ?? tipoMap.associado);
+</script>
+
+<style lang="scss" scoped>
+@use "sass:map";
+@use "src/css/quasar.variables.scss" as *;
+
+.badge-pill {
+  background: map.get($colors, "violet-normal");
+  border-radius: 999px;
+  color: white;
+}
+.badge-label {
+  font-size: 13px;
+  font-weight: 600;
+  color: white;
+}
+</style>

+ 25 - 3
src/router/routes.js

@@ -45,9 +45,31 @@ const routes = [
         path: "",
         name: "LoginPage",
         component: () => import("pages/login/LoginPage.vue"),
-        meta: {
-          title: "Login",
-        },
+        meta: { title: "Login" },
+      },
+      {
+        path: "recuperar-senha",
+        name: "ForgotPasswordPage",
+        component: () => import("pages/login/ForgotPasswordPage.vue"),
+        meta: { title: "Recuperar Senha" },
+      },
+      {
+        path: "verificar-email",
+        name: "VerifyEmailPage",
+        component: () => import("pages/login/VerifyEmailPage.vue"),
+        meta: { title: "Verificar E-mail" },
+      },
+      {
+        path: "digitar-codigo",
+        name: "VerifyCodePage",
+        component: () => import("pages/login/VerifyCodePage.vue"),
+        meta: { title: "Digitar Código" },
+      },
+      {
+        path: "nova-senha",
+        name: "ResetPasswordPage",
+        component: () => import("pages/login/ResetPasswordPage.vue"),
+        meta: { title: "Nova Senha" },
       },
     ],
   },