VerifyCodePage.vue 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. <template>
  2. <q-page class="login-page column items-center justify-center">
  3. <div class="login-overlay" />
  4. <div class="login-card column items-center">
  5. <div class="column items-center fields-card">
  6. <q-img :src="Logo" class="login-logo q-mb-lg" />
  7. <UserTypeBadge :tipo="tipo" />
  8. <p class="login-title">{{ $t("auth.enter_code_title") }}</p>
  9. <div class="code-input-container q-mb-md" @click="focusCodeInput">
  10. <div class="code-slots row items-end justify-center">
  11. <div
  12. v-for="i in 6"
  13. :key="i"
  14. class="code-slot"
  15. :class="{
  16. 'code-slot--filled': (form.codigo?.length ?? 0) >= i,
  17. 'code-slot--active': inputFocused && (form.codigo?.length ?? 0) === i - 1,
  18. }"
  19. >
  20. <span class="code-char">{{ form.codigo?.[i - 1] ?? "" }}</span>
  21. </div>
  22. </div>
  23. <input
  24. ref="codeInputRef"
  25. v-model="form.codigo"
  26. class="code-hidden-input"
  27. maxlength="6"
  28. inputmode="numeric"
  29. autocomplete="one-time-code"
  30. :disabled="loading"
  31. @focus="inputFocused = true"
  32. @blur="inputFocused = false"
  33. @input="onCodeInput"
  34. @keydown="filterNonNumeric"
  35. />
  36. </div>
  37. <div v-if="loading" class="q-mb-md">
  38. <q-spinner color="violet-normal" size="24px" />
  39. </div>
  40. <p class="email-text q-py-md">{{ email }}</p>
  41. <q-btn
  42. unelevated
  43. :label="$t('common.actions.resend_email')"
  44. color="violet-normal"
  45. class="resend-btn"
  46. :loading="resending"
  47. @click="onResend"
  48. />
  49. <div class="column items-center q-mt-sm q-pt-md">
  50. <a href="#" class="login-link q-py-md" @click.prevent="goToLogin">
  51. {{ $t("auth.remember_password") }}
  52. <strong>{{ $t("auth.do_login") }}</strong>
  53. </a>
  54. <a href="#" class="login-link q-mt-xs q-pt-md" @click.prevent="goBack">
  55. <q-icon name="mdi-arrow-left" size="12px" class="q-mr-xs" />
  56. {{ $t("auth.back_to_site") }}
  57. </a>
  58. </div>
  59. </div>
  60. </div>
  61. </q-page>
  62. </template>
  63. <script setup>
  64. import { ref, nextTick } from "vue";
  65. import { useRouter, useRoute } from "vue-router";
  66. import { useQuasar } from "quasar";
  67. import { useSubmitHandler } from "src/composables/useSubmitHandler";
  68. import { verifyCode, forgotPassword } from "src/api/auth";
  69. import Logo from "src/assets/logo_serprati.svg";
  70. import UserTypeBadge from "./components/UserTypeBadge.vue";
  71. const router = useRouter();
  72. const route = useRoute();
  73. const $q = useQuasar();
  74. const email = route.query.email ?? "";
  75. const tipo = route.query.tipo ?? "administrador";
  76. const codeInputRef = ref(null);
  77. const inputFocused = ref(false);
  78. const resending = ref(false);
  79. const form = ref({ codigo: "" });
  80. const { loading, execute: submitForm } = useSubmitHandler({
  81. onSuccess: () =>
  82. router.push({ name: "ResetPasswordPage", query: { email, tipo, codigo: form.value.codigo } }),
  83. onError: () => {
  84. $q.notify({ type: "negative", message: "Código inválido. Verifique e tente novamente." });
  85. form.value.codigo = "";
  86. nextTick(() => codeInputRef.value?.focus());
  87. },
  88. });
  89. const focusCodeInput = () => codeInputRef.value?.focus();
  90. const filterNonNumeric = (e) => {
  91. const allowed = ["Backspace", "Delete", "Tab", "ArrowLeft", "ArrowRight"];
  92. if (!allowed.includes(e.key) && !/^\d$/.test(e.key)) {
  93. e.preventDefault();
  94. }
  95. };
  96. const onCodeInput = async () => {
  97. form.value.codigo = (form.value.codigo ?? "").replace(/\D/g, "").slice(0, 6);
  98. if (form.value.codigo.length === 6) {
  99. await submitForm(() => verifyCode(email, form.value.codigo));
  100. }
  101. };
  102. const onResend = async () => {
  103. resending.value = true;
  104. try {
  105. await forgotPassword(email, tipo);
  106. $q.notify({ type: "positive", message: "Código reenviado!" });
  107. } catch {
  108. $q.notify({ type: "negative", message: "Não foi possível reenviar. Tente novamente." });
  109. } finally {
  110. resending.value = false;
  111. }
  112. };
  113. const goToLogin = () => router.push({ name: "LoginPage" });
  114. const goBack = () => router.push({ name: "LoginPage" });
  115. </script>
  116. <style lang="scss" scoped>
  117. @use "sass:map";
  118. @use "src/css/quasar.variables.scss" as *;
  119. .login-page {
  120. min-height: 100dvh;
  121. background-image: url("src/assets/pessoas_fundo.jpg");
  122. background-size: cover;
  123. background-position: center;
  124. position: relative;
  125. }
  126. .login-overlay {
  127. position: absolute;
  128. inset: 0;
  129. background: rgba(74, 20, 140, 0.48);
  130. z-index: 0;
  131. }
  132. .login-card {
  133. z-index: 1;
  134. display: flex;
  135. align-items: center;
  136. justify-content: center;
  137. width: calc(100% - 32px);
  138. min-height: calc(100dvh - 32px);
  139. background: rgba(255, 255, 255, 0.747);
  140. border-radius: 16px;
  141. box-shadow: 0 4px 32px rgba(0, 0, 0, 0.15);
  142. }
  143. .fields-card {
  144. width: 100%;
  145. max-width: 600px;
  146. padding: 32px;
  147. border-radius: 12px;
  148. }
  149. .login-logo {
  150. width: 380px;
  151. max-width: 100%;
  152. }
  153. .login-title {
  154. color: map.get($colors, "violet-normal");
  155. font-size: 18px;
  156. font-weight: 700;
  157. margin: 0 0 24px;
  158. text-align: center;
  159. }
  160. .code-input-container {
  161. position: relative;
  162. cursor: text;
  163. width: 100%;
  164. display: flex;
  165. justify-content: center;
  166. }
  167. .code-hidden-input {
  168. position: absolute;
  169. opacity: 0;
  170. pointer-events: none;
  171. width: 1px;
  172. height: 1px;
  173. top: 0;
  174. left: 0;
  175. }
  176. .code-slots {
  177. display: flex;
  178. gap: 16px;
  179. }
  180. .code-slot {
  181. width: 40px;
  182. height: 52px;
  183. display: flex;
  184. align-items: flex-end;
  185. justify-content: center;
  186. padding-bottom: 6px;
  187. border-bottom: 2.5px solid map.get($colors, "violet-normal");
  188. position: relative;
  189. transition: border-color 0.15s;
  190. &--filled {
  191. border-bottom-color: map.get($colors, "violet-normal-hover");
  192. }
  193. &--active {
  194. border-bottom-color: map.get($colors, "violet-normal-active");
  195. &::after {
  196. content: "";
  197. position: absolute;
  198. bottom: 6px;
  199. width: 1.5px;
  200. height: 20px;
  201. background: map.get($colors, "violet-normal");
  202. animation: blink-cursor 1s step-end infinite;
  203. }
  204. }
  205. }
  206. .code-char {
  207. font-size: 22px;
  208. font-weight: 700;
  209. color: map.get($colors, "violet-normal");
  210. line-height: 1;
  211. }
  212. @keyframes blink-cursor {
  213. 0%, 100% { opacity: 1; }
  214. 50% { opacity: 0; }
  215. }
  216. .email-text {
  217. color: map.get($colors, "violet-normal");
  218. font-size: 14px;
  219. font-weight: 500;
  220. text-align: center;
  221. margin: 0;
  222. }
  223. .resend-btn {
  224. font-size: 13px;
  225. height: 44px;
  226. font-weight: 600;
  227. letter-spacing: 0.5px;
  228. }
  229. .login-link {
  230. color: map.get($colors, "violet-normal");
  231. font-size: 13px;
  232. text-decoration: none;
  233. cursor: pointer;
  234. &:hover {
  235. color: map.get($colors, "violet-normal-hover");
  236. text-decoration: underline;
  237. }
  238. }
  239. @media (max-width: 599px) {
  240. .fields-card {
  241. padding: 52px 16px 20px;
  242. }
  243. .login-logo {
  244. width: 240px;
  245. }
  246. }
  247. </style>