FirstAccessFormPage.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546
  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-md" />
  7. <p class="login-title">{{ $t("auth.first_access.form_title") }}</p>
  8. <p class="login-description">{{ $t("auth.first_access.form_hint") }}</p>
  9. <div class="flex flex-center q-mb-md">
  10. <div class="avatar-wrap">
  11. <q-avatar size="96px" class="first-access-avatar">
  12. <img v-if="photoPreview" :src="photoPreview" class="avatar-img" />
  13. <q-icon v-else name="mdi-account" size="52px" color="white" />
  14. </q-avatar>
  15. <q-btn
  16. round
  17. unelevated
  18. size="sm"
  19. icon="mdi-camera-outline"
  20. class="avatar-edit-btn"
  21. color="violet-normal"
  22. text-color="white"
  23. @click="photoInputRef.click()"
  24. />
  25. <input
  26. ref="photoInputRef"
  27. type="file"
  28. accept="image/*"
  29. capture="user"
  30. style="display: none"
  31. @change="onPhotoSelected"
  32. />
  33. </div>
  34. </div>
  35. <div
  36. class="text-center text-caption q-mb-md"
  37. :class="photoError ? 'text-negative' : 'text-grey-7'"
  38. >
  39. {{ photoError || $t("auth.first_access.photo_hint") }}
  40. </div>
  41. <q-form
  42. ref="formRef"
  43. class="full-width"
  44. autocomplete="off"
  45. @submit="onSubmit"
  46. >
  47. <DefaultInput
  48. v-model="form.registration"
  49. v-model:error="validationErrors.registration"
  50. readonly
  51. bg-color="grey-2"
  52. :label="$t('auth.first_access.registration')"
  53. />
  54. <DefaultInput
  55. v-model="form.cpf"
  56. v-model:error="validationErrors.cpf"
  57. :readonly="isLocked('cpf')"
  58. :bg-color="isLocked('cpf') ? 'grey-2' : undefined"
  59. :mask="masks.Brasil.cpf"
  60. :label="$t('common.terms.cpf')"
  61. :rules="[inputRules.required, inputRules.cpf]"
  62. />
  63. <DefaultInput
  64. v-model="form.name"
  65. v-model:error="validationErrors.name"
  66. :readonly="isLocked('name')"
  67. :bg-color="isLocked('name') ? 'grey-2' : undefined"
  68. :label="$t('common.terms.name')"
  69. :rules="[inputRules.required, inputRules.minTrimmed(3)]"
  70. />
  71. <DefaultInput
  72. v-model="form.last_name"
  73. v-model:error="validationErrors.last_name"
  74. :readonly="isLocked('last_name')"
  75. :bg-color="isLocked('last_name') ? 'grey-2' : undefined"
  76. :label="$t('auth.first_access.last_name')"
  77. :rules="[inputRules.required, inputRules.minTrimmed(3)]"
  78. />
  79. <DefaultInput
  80. v-model="form.email"
  81. v-model:error="validationErrors.email"
  82. type="email"
  83. :readonly="isLocked('email')"
  84. :bg-color="isLocked('email') ? 'grey-2' : undefined"
  85. :label="$t('common.terms.email')"
  86. :rules="[inputRules.required, inputRules.email]"
  87. />
  88. <DefaultInput
  89. v-model="form.phone"
  90. v-model:error="validationErrors.phone"
  91. :readonly="isLocked('phone')"
  92. :bg-color="isLocked('phone') ? 'grey-2' : undefined"
  93. mask="(##) #####-####"
  94. :label="$t('auth.first_access.whatsapp')"
  95. :rules="[inputRules.required]"
  96. />
  97. <DefaultSelect
  98. v-model="form.position_id"
  99. v-model:error="validationErrors.position_id"
  100. :options="positionOptions"
  101. option-value="id"
  102. option-label="name"
  103. emit-value
  104. map-options
  105. :readonly="isLocked('position_id')"
  106. :bg-color="isLocked('position_id') ? 'grey-2' : undefined"
  107. :label="$t('associado.position')"
  108. :rules="[inputRules.required]"
  109. />
  110. <DefaultSelect
  111. v-model="form.sector_id"
  112. v-model:error="validationErrors.sector_id"
  113. :options="sectorOptions"
  114. option-value="id"
  115. option-label="name"
  116. emit-value
  117. map-options
  118. :readonly="isLocked('sector_id')"
  119. :bg-color="isLocked('sector_id') ? 'grey-2' : undefined"
  120. :label="$t('associado.sector')"
  121. :rules="[inputRules.required]"
  122. />
  123. <DefaultPasswordInput
  124. v-model="form.password"
  125. v-model:error="validationErrors.password"
  126. :label="$t('common.terms.password')"
  127. :rules="[inputRules.required, inputRules.password]"
  128. />
  129. <DefaultPasswordInput
  130. v-model="form.password_confirmation"
  131. v-model:error="validationErrors.password_confirmation"
  132. :label="$t('auth.confirm_password')"
  133. :rules="[
  134. inputRules.required,
  135. inputRules.samePassword(form.password),
  136. ]"
  137. />
  138. <p class="password-hint">{{ $t("auth.password_hint") }}</p>
  139. <q-btn
  140. class="full-width q-mt-md login-btn"
  141. color="violet-normal"
  142. :label="$t('auth.first_access.finish')"
  143. type="submit"
  144. unelevated
  145. :loading
  146. >
  147. <template #loading>
  148. <q-spinner />
  149. </template>
  150. </q-btn>
  151. </q-form>
  152. <a href="#" class="forgot-link q-mt-md" @click.prevent="goBack">
  153. <q-icon name="mdi-arrow-left" size="12px" class="q-mr-xs" />
  154. {{ $t("auth.first_access.back_to_login") }}
  155. </a>
  156. </div>
  157. </div>
  158. </q-page>
  159. </template>
  160. <script setup>
  161. import { ref, watch, onBeforeMount, useTemplateRef } from "vue";
  162. import { useQuasar } from "quasar";
  163. import { useI18n } from "vue-i18n";
  164. import { useRouter } from "vue-router";
  165. import { useAuth } from "src/composables/useAuth";
  166. import { useInputRules } from "src/composables/useInputRules";
  167. import { useSubmitHandler } from "src/composables/useSubmitHandler";
  168. import {
  169. registerFirstAccess,
  170. getPublicPositions,
  171. getPublicSectors,
  172. } from "src/api/firstAccess";
  173. import { getFirstAccessData, clearFirstAccessData } from "./firstAccessStorage";
  174. import masks from "src/helpers/masks";
  175. import compressImage from "src/helpers/compressImage";
  176. import Logo from "src/assets/logo_serprati.svg";
  177. import DefaultInput from "src/components/defaults/DefaultInput.vue";
  178. import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
  179. import DefaultPasswordInput from "src/components/defaults/DefaultPasswordInput.vue";
  180. const MAX_PHOTO_SIZE_MB = 5;
  181. const MAX_PHOTO_SIZE_BYTES = MAX_PHOTO_SIZE_MB * 1024 * 1024;
  182. const router = useRouter();
  183. const $q = useQuasar();
  184. const { t } = useI18n();
  185. const { inputRules } = useInputRules();
  186. const { login } = useAuth();
  187. const formRef = useTemplateRef("formRef");
  188. const photoInputRef = useTemplateRef("photoInputRef");
  189. const token = ref(null);
  190. const lockedFields = ref([]);
  191. const photoFile = ref(null);
  192. const photoPreview = ref(null);
  193. const photoError = ref(null);
  194. const positionOptions = ref([]);
  195. const sectorOptions = ref([]);
  196. const form = ref({
  197. registration: null,
  198. cpf: null,
  199. name: null,
  200. last_name: null,
  201. email: null,
  202. phone: null,
  203. position_id: null,
  204. sector_id: null,
  205. password: null,
  206. password_confirmation: null,
  207. });
  208. const isLocked = (field) => lockedFields.value.includes(field);
  209. const unlockField = (field) => {
  210. lockedFields.value = lockedFields.value.filter((locked) => locked !== field);
  211. };
  212. const notifyUnmappedError = (error) => {
  213. const response = error?.response;
  214. if (!response) {
  215. $q.notify({
  216. type: "negative",
  217. timeout: 10000,
  218. message: t("http.errors.no_connection", {
  219. detail: error?.message ?? "erro desconhecido",
  220. }),
  221. });
  222. return;
  223. }
  224. const unmapped = Object.entries(response.data?.errors ?? {})
  225. .filter(([field]) => !(field in form.value))
  226. .map(([field, messages]) => `${field}: ${[messages].flat()[0]}`);
  227. if (unmapped.length) {
  228. $q.notify({
  229. type: "negative",
  230. timeout: 10000,
  231. message: t("http.errors.unexpected", { detail: unmapped.join(" | ") }),
  232. });
  233. return;
  234. }
  235. if (!response.data?.message && response.status !== 422) {
  236. $q.notify({
  237. type: "negative",
  238. timeout: 10000,
  239. message: t("http.errors.unexpected", {
  240. detail: `HTTP ${response.status}`,
  241. }),
  242. });
  243. }
  244. };
  245. const {
  246. loading,
  247. validationErrors,
  248. execute: submitForm,
  249. } = useSubmitHandler({ formRef, onError: notifyUnmappedError });
  250. const prefillRules = {
  251. name: [inputRules.required, inputRules.minTrimmed(3)],
  252. last_name: [inputRules.required, inputRules.minTrimmed(3)],
  253. cpf: [inputRules.required, inputRules.cpf],
  254. email: [inputRules.required, inputRules.email],
  255. phone: [inputRules.required],
  256. position_id: [inputRules.required],
  257. sector_id: [inputRules.required],
  258. };
  259. const unlockInvalidFields = () => {
  260. Object.entries(prefillRules).forEach(([field, rules]) => {
  261. if (!isLocked(field)) return;
  262. if (rules.some((rule) => rule(form.value[field]) !== true)) {
  263. unlockField(field);
  264. }
  265. });
  266. };
  267. watch(
  268. validationErrors,
  269. (errors) => Object.keys(errors ?? {}).forEach((field) => unlockField(field)),
  270. { deep: true },
  271. );
  272. const onPhotoSelected = async (event) => {
  273. const file = event.target.files?.[0];
  274. event.target.value = "";
  275. if (!file) return;
  276. const photo = await compressImage(file);
  277. if (photo.size > MAX_PHOTO_SIZE_BYTES) {
  278. const message = t("auth.first_access.photo_too_large", {
  279. size: (photo.size / 1024 / 1024).toFixed(1),
  280. max: MAX_PHOTO_SIZE_MB,
  281. });
  282. photoError.value = message;
  283. $q.notify({ type: "negative", message, timeout: 8000 });
  284. return;
  285. }
  286. photoFile.value = photo;
  287. photoPreview.value = URL.createObjectURL(photo);
  288. photoError.value = null;
  289. };
  290. const onSubmit = async () => {
  291. if (!photoFile.value && !photoPreview.value) {
  292. photoError.value = t("auth.first_access.photo_required");
  293. return;
  294. }
  295. const password = form.value.password;
  296. const result = await submitForm(() =>
  297. registerFirstAccess({
  298. ...form.value,
  299. token: token.value,
  300. photo: photoFile.value,
  301. }),
  302. );
  303. if (!result) return;
  304. clearFirstAccessData();
  305. if (!result.can_login) {
  306. $q.notify({
  307. type: "warning",
  308. message: result.message ?? t("auth.first_access.pending_approval"),
  309. timeout: 8000,
  310. });
  311. router.push({ name: "LoginPage" });
  312. return;
  313. }
  314. try {
  315. await login(form.value.registration, password, { skipSuccessNotify: true });
  316. $q.notify({ type: "positive", message: t("auth.first_access.success") });
  317. router.push({ name: "CarteirinhaPage" });
  318. } catch {
  319. $q.notify({
  320. type: "positive",
  321. message: t("auth.first_access.success_login"),
  322. });
  323. router.push({ name: "LoginPage" });
  324. }
  325. };
  326. const goBack = () => {
  327. clearFirstAccessData();
  328. router.push({ name: "LoginPage" });
  329. };
  330. onBeforeMount(async () => {
  331. const data = getFirstAccessData();
  332. if (!data?.token) {
  333. router.replace({ name: "FirstAccessPage" });
  334. return;
  335. }
  336. token.value = data.token;
  337. form.value.registration = data.registration;
  338. if (data.user) {
  339. const { photo_url, ...fields } = data.user;
  340. Object.entries(fields).forEach(([key, value]) => {
  341. if (value === null || value === "") return;
  342. form.value[key] = value;
  343. lockedFields.value.push(key);
  344. });
  345. if (photo_url) {
  346. photoPreview.value = photo_url;
  347. }
  348. unlockInvalidFields();
  349. }
  350. try {
  351. const [positions, sectors] = await Promise.all([
  352. getPublicPositions(),
  353. getPublicSectors(),
  354. ]);
  355. positionOptions.value = positions ?? [];
  356. sectorOptions.value = sectors ?? [];
  357. } catch {
  358. $q.notify({ type: "negative", message: t("http.errors.failed") });
  359. }
  360. });
  361. </script>
  362. <style lang="scss" scoped>
  363. @use "sass:map";
  364. @use "src/css/quasar.variables.scss" as *;
  365. .login-page {
  366. min-height: 100dvh;
  367. background-image: url("src/assets/pessoas_fundo.jpg");
  368. background-size: cover;
  369. background-position: center;
  370. position: relative;
  371. }
  372. .login-overlay {
  373. position: absolute;
  374. inset: 0;
  375. background: rgba(74, 20, 140, 0.48);
  376. z-index: 0;
  377. }
  378. .login-card {
  379. z-index: 1;
  380. display: flex;
  381. align-items: center;
  382. justify-content: center;
  383. width: calc(100% - 32px);
  384. min-height: calc(100dvh - 32px);
  385. margin: 16px 0;
  386. background: rgba(255, 255, 255, 0.747);
  387. border-radius: 16px;
  388. box-shadow: 0 4px 32px rgba(0, 0, 0, 0.15);
  389. padding-top: env(safe-area-inset-top);
  390. padding-bottom: env(safe-area-inset-bottom);
  391. }
  392. .fields-card {
  393. width: 100%;
  394. max-width: 400px;
  395. padding: 32px;
  396. border-radius: 12px;
  397. :deep(.q-field__native),
  398. :deep(.q-field__input) {
  399. color: $color-text !important;
  400. }
  401. :deep(.q-field__control) {
  402. background: $surface !important;
  403. }
  404. :deep(.q-field__label) {
  405. color: $color-text-2;
  406. }
  407. :deep(.q-field--focused .q-field__label) {
  408. color: $violet-normal;
  409. }
  410. :deep(.q-field--float:not(.q-field--focused) .q-field__label) {
  411. color: $neutral-dark-active;
  412. }
  413. }
  414. .login-logo {
  415. width: 220px;
  416. max-width: 100%;
  417. }
  418. .login-title {
  419. color: map.get($colors, "violet-normal");
  420. font-size: 18px;
  421. font-weight: 700;
  422. margin: 0 0 8px;
  423. text-align: center;
  424. }
  425. .login-description {
  426. color: map.get($colors, "text-2");
  427. font-size: 13px;
  428. text-align: center;
  429. margin: 0 0 16px;
  430. }
  431. .password-hint {
  432. color: map.get($colors, "text-2");
  433. font-size: 12px;
  434. margin: 0;
  435. }
  436. .avatar-wrap {
  437. position: relative;
  438. display: inline-block;
  439. }
  440. .first-access-avatar {
  441. background: rgba(112, 32, 130, 0.18) !important;
  442. }
  443. .avatar-img {
  444. width: 100%;
  445. height: 100%;
  446. object-fit: cover;
  447. }
  448. .avatar-edit-btn {
  449. position: absolute;
  450. bottom: -2px;
  451. right: -2px;
  452. }
  453. .login-btn {
  454. height: 44px;
  455. font-size: 15px;
  456. font-weight: 600;
  457. letter-spacing: 0.5px;
  458. }
  459. .forgot-link {
  460. color: map.get($colors, "violet-normal");
  461. font-size: 13px;
  462. text-decoration: none;
  463. cursor: pointer;
  464. &:hover {
  465. color: map.get($colors, "violet-normal-hover");
  466. text-decoration: underline;
  467. }
  468. }
  469. </style>