Selaa lähdekoodia

feat: ✨ feat (primeiro acesso associado) criado fluxo de primeiro acesso do associado

foi criado o fluxo de primeiro acesso do associado, acessando com cracha e preenchendo informacoes faltantes

fase:dev | origin:escopo
Gustavo Zanatta 14 tuntia sitten
vanhempi
commit
06002d7029

+ 30 - 0
src/api/firstAccess.js

@@ -0,0 +1,30 @@
+import api from "src/api";
+
+export const checkFirstAccess = async (registration) => {
+  const { data } = await api.post("/first-access/check", { registration });
+  return { ...data.payload, message: data.message };
+};
+
+export const registerFirstAccess = async (payload) => {
+  const formData = new FormData();
+  Object.entries(payload).forEach(([key, value]) => {
+    if (value !== null && value !== undefined) {
+      formData.append(key, value);
+    }
+  });
+
+  const { data } = await api.post("/first-access/register", formData, {
+    headers: { "Content-Type": "multipart/form-data" },
+  });
+  return data.payload;
+};
+
+export const getPublicPositions = async () => {
+  const { data } = await api.get("/public/positions");
+  return data.payload;
+};
+
+export const getPublicSectors = async () => {
+  const { data } = await api.get("/public/sectors");
+  return data.payload;
+};

+ 17 - 0
src/components/CompleteProfileDialog.vue

@@ -82,6 +82,18 @@
             />
           </div>
 
+          <div class="col-12 col-sm-6">
+            <q-input
+              v-model="form.phone"
+              outlined
+              dense
+              mask="(##) #####-####"
+              :label="$t('auth.first_access.whatsapp')"
+              :readonly="!!initialUser.phone"
+              :bg-color="initialUser.phone ? 'grey-2' : 'white'"
+            />
+          </div>
+
           <div class="col-12 col-sm-6">
             <q-select
               v-model="form.position_id"
@@ -186,6 +198,7 @@ const form = ref({
   name: "",
   cpf: "",
   email: "",
+  phone: "",
   position_id: null,
   sector_id: null,
   registration: "",
@@ -198,6 +211,7 @@ const canSave = computed(() => {
     !!form.value.name?.trim() &&
     !!form.value.cpf?.trim() &&
     !!form.value.email?.trim() &&
+    !!form.value.phone?.trim() &&
     !!form.value.position_id &&
     !!form.value.sector_id
   );
@@ -227,6 +241,7 @@ const onSave = async () => {
     if (!initialUser.value.name)        payload.name        = form.value.name;
     if (!initialUser.value.cpf)         payload.cpf         = form.value.cpf;
     if (!initialUser.value.email)       payload.email       = form.value.email;
+    if (!initialUser.value.phone)       payload.phone       = form.value.phone;
     if (!initialUser.value.position_id) payload.position_id = form.value.position_id;
     if (!initialUser.value.sector_id)   payload.sector_id   = form.value.sector_id;
 
@@ -249,6 +264,7 @@ onMounted(async () => {
     name:        user.name        || null,
     cpf:         user.cpf         || null,
     email:       user.email       || null,
+    phone:       user.phone       || null,
     position_id: user.position_id ?? user.position?.id ?? null,
     sector_id:   user.sector_id   ?? user.sector?.id   ?? null,
     registration:  user.registration  || null,
@@ -259,6 +275,7 @@ onMounted(async () => {
     name:          initialUser.value.name        || "",
     cpf:           initialUser.value.cpf         || "",
     email:         initialUser.value.email       || "",
+    phone:         initialUser.value.phone       || "",
     position_id:   initialUser.value.position_id || null,
     sector_id:     initialUser.value.sector_id   || null,
     registration:  initialUser.value.registration  || "",

+ 18 - 2
src/i18n/locales/en.json

@@ -260,7 +260,23 @@
     "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."
+    "wrong_type": "User found, but the selected type is incorrect. Please select the correct login type.",
+    "first_access": {
+      "button": "My First Access",
+      "title": "First Access",
+      "registration": "Badge",
+      "registration_hint": "Enter your badge number to get started.",
+      "form_title": "Complete your registration",
+      "form_hint": "Fill in the fields below to finish your first access. Grey fields are filled in by the administration.",
+      "whatsapp": "WhatsApp",
+      "photo_hint": "Tap the icon to add your photo",
+      "photo_required": "The photo is required",
+      "finish": "Finish Registration",
+      "success": "Registration completed successfully!",
+      "success_login": "Registration completed! Sign in to access the system.",
+      "already_done": "The first access for this badge has already been completed. Please sign in or use \"Forgot your password?\".",
+      "back_to_login": "Back to login"
+    }
   },
   "business": {
     "advertise": "Advertise",
@@ -726,4 +742,4 @@
     "qr_code": "Read QR CODE",
     "not_found": "Associate not found"
   }
-}
+}

+ 18 - 2
src/i18n/locales/es.json

@@ -260,7 +260,23 @@
     "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."
+    "wrong_type": "Usuario encontrado, pero el tipo seleccionado es incorrecto. Seleccione el tipo correcto.",
+    "first_access": {
+      "button": "Mi Primer Acceso",
+      "title": "Primer Acceso",
+      "registration": "Credencial",
+      "registration_hint": "Ingrese el número de su credencial para comenzar.",
+      "form_title": "Complete su registro",
+      "form_hint": "Complete los datos a continuación para finalizar su primer acceso. Los campos en gris son completados por la administración.",
+      "whatsapp": "WhatsApp",
+      "photo_hint": "Toque el ícono para agregar su foto",
+      "photo_required": "La foto es obligatoria",
+      "finish": "Finalizar Registro",
+      "success": "¡Registro completado con éxito!",
+      "success_login": "¡Registro completado! Inicie sesión para acceder al sistema.",
+      "already_done": "El primer acceso de esta credencial ya fue realizado. Inicie sesión normalmente o use \"¿Olvidó su contraseña?\".",
+      "back_to_login": "Volver al inicio de sesión"
+    }
   },
   "business": {
     "advertise": "Anunciar",
@@ -726,4 +742,4 @@
     "qr_code": "Leer QR CODE",
     "not_found": "Asociado no encontrado"
   }
-}
+}

+ 18 - 2
src/i18n/locales/pt.json

@@ -260,7 +260,23 @@
     "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."
+    "wrong_type": "Usuário encontrado, mas o tipo selecionado está incorreto. Selecione o login correto.",
+    "first_access": {
+      "button": "Meu Primeiro Acesso",
+      "title": "Primeiro Acesso",
+      "registration": "Crachá",
+      "registration_hint": "Informe o número do seu crachá para começar.",
+      "form_title": "Complete seu cadastro",
+      "form_hint": "Preencha os dados abaixo para concluir seu primeiro acesso. Os campos em cinza são preenchidos pela administração.",
+      "whatsapp": "WhatsApp",
+      "photo_hint": "Toque no ícone para adicionar sua foto",
+      "photo_required": "A foto é obrigatória",
+      "finish": "Concluir Cadastro",
+      "success": "Cadastro concluído com sucesso!",
+      "success_login": "Cadastro concluído! Faça login para acessar o sistema.",
+      "already_done": "O primeiro acesso deste crachá já foi realizado. Faça login normalmente ou use \"Esqueceu a senha?\".",
+      "back_to_login": "Voltar ao login"
+    }
   },
   "business": {
     "advertise": "Anunciar",
@@ -723,4 +739,4 @@
     "qr_code": "Ler QR CODE",
     "not_found": "Associado não encontrado"
   }
-}
+}

+ 1 - 0
src/layouts/MainLayout.vue

@@ -81,6 +81,7 @@ const isProfileIncomplete = (user) => {
     !user.name ||
     !user.cpf ||
     !user.email ||
+    !user.phone ||
     !(user.position_id ?? user.position?.id) ||
     !(user.sector_id   ?? user.sector?.id)
   );

+ 433 - 0
src/pages/login/FirstAccessFormPage.vue

@@ -0,0 +1,433 @@
+<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-md" />
+
+        <p class="login-title">{{ $t("auth.first_access.form_title") }}</p>
+        <p class="login-description">{{ $t("auth.first_access.form_hint") }}</p>
+
+        <div class="flex flex-center q-mb-md">
+          <div class="avatar-wrap">
+            <q-avatar size="96px" class="first-access-avatar">
+              <img v-if="photoPreview" :src="photoPreview" class="avatar-img" />
+              <q-icon v-else name="mdi-account" size="52px" color="white" />
+            </q-avatar>
+            <q-btn
+              round
+              unelevated
+              size="sm"
+              icon="mdi-camera-outline"
+              class="avatar-edit-btn"
+              color="violet-normal"
+              text-color="white"
+              @click="photoInputRef.click()"
+            />
+            <input
+              ref="photoInputRef"
+              type="file"
+              accept="image/*"
+              capture="user"
+              style="display: none"
+              @change="onPhotoSelected"
+            />
+          </div>
+        </div>
+        <div
+          class="text-center text-caption q-mb-md"
+          :class="photoError ? 'text-negative' : 'text-grey-7'"
+        >
+          {{ photoError || $t("auth.first_access.photo_hint") }}
+        </div>
+
+        <q-form
+          ref="formRef"
+          class="full-width"
+          autocomplete="off"
+          @submit="onSubmit"
+        >
+          <DefaultInput
+            v-model="form.registration"
+            v-model:error="validationErrors.registration"
+            readonly
+            bg-color="grey-2"
+            :label="$t('auth.first_access.registration')"
+          />
+
+          <DefaultInput
+            v-model="form.name"
+            v-model:error="validationErrors.name"
+            :readonly="isLocked('name')"
+            :bg-color="isLocked('name') ? 'grey-2' : undefined"
+            :label="$t('common.terms.name')"
+            :rules="[inputRules.required]"
+          />
+
+          <DefaultInput
+            v-model="form.cpf"
+            v-model:error="validationErrors.cpf"
+            :readonly="isLocked('cpf')"
+            :bg-color="isLocked('cpf') ? 'grey-2' : undefined"
+            :mask="masks.Brasil.cpf"
+            :label="$t('common.terms.cpf')"
+            :rules="[inputRules.required, inputRules.cpf]"
+          />
+
+          <DefaultInput
+            v-model="form.email"
+            v-model:error="validationErrors.email"
+            type="email"
+            :readonly="isLocked('email')"
+            :bg-color="isLocked('email') ? 'grey-2' : undefined"
+            :label="$t('common.terms.email')"
+            :rules="[inputRules.required, inputRules.email]"
+          />
+
+          <DefaultInput
+            v-model="form.phone"
+            v-model:error="validationErrors.phone"
+            :readonly="isLocked('phone')"
+            :bg-color="isLocked('phone') ? 'grey-2' : undefined"
+            mask="(##) #####-####"
+            :label="$t('auth.first_access.whatsapp')"
+            :rules="[inputRules.required]"
+          />
+
+          <DefaultSelect
+            v-model="form.position_id"
+            v-model:error="validationErrors.position_id"
+            :options="positionOptions"
+            option-value="id"
+            option-label="name"
+            emit-value
+            map-options
+            :readonly="isLocked('position_id')"
+            :bg-color="isLocked('position_id') ? 'grey-2' : undefined"
+            :label="$t('associado.position')"
+            :rules="[inputRules.required]"
+          />
+
+          <DefaultSelect
+            v-model="form.sector_id"
+            v-model:error="validationErrors.sector_id"
+            :options="sectorOptions"
+            option-value="id"
+            option-label="name"
+            emit-value
+            map-options
+            :readonly="isLocked('sector_id')"
+            :bg-color="isLocked('sector_id') ? 'grey-2' : undefined"
+            :label="$t('associado.sector')"
+            :rules="[inputRules.required]"
+          />
+
+          <DefaultPasswordInput
+            v-model="form.password"
+            v-model:error="validationErrors.password"
+            :label="$t('common.terms.password')"
+            :rules="[inputRules.required, inputRules.password]"
+          />
+
+          <DefaultPasswordInput
+            v-model="form.password_confirmation"
+            v-model:error="validationErrors.password_confirmation"
+            :label="$t('auth.confirm_password')"
+            :rules="[inputRules.required, inputRules.samePassword(form.password)]"
+          />
+
+          <p class="password-hint">{{ $t("auth.password_hint") }}</p>
+
+          <q-btn
+            class="full-width q-mt-md login-btn"
+            color="violet-normal"
+            :label="$t('auth.first_access.finish')"
+            type="submit"
+            unelevated
+            :loading
+          >
+            <template #loading>
+              <q-spinner />
+            </template>
+          </q-btn>
+        </q-form>
+
+        <a href="#" class="forgot-link q-mt-md" @click.prevent="goBack">
+          <q-icon name="mdi-arrow-left" size="12px" class="q-mr-xs" />
+          {{ $t("auth.first_access.back_to_login") }}
+        </a>
+      </div>
+    </div>
+  </q-page>
+</template>
+
+<script setup>
+import { ref, onBeforeMount, useTemplateRef } from "vue";
+import { useQuasar } from "quasar";
+import { useI18n } from "vue-i18n";
+import { useRouter } from "vue-router";
+import { useAuth } from "src/composables/useAuth";
+import { useInputRules } from "src/composables/useInputRules";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+import {
+  registerFirstAccess,
+  getPublicPositions,
+  getPublicSectors,
+} from "src/api/firstAccess";
+import { getFirstAccessData, clearFirstAccessData } from "./firstAccessStorage";
+import masks from "src/helpers/masks";
+
+import Logo from "src/assets/logo_serprati.svg";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+import DefaultPasswordInput from "src/components/defaults/DefaultPasswordInput.vue";
+
+const router = useRouter();
+const $q = useQuasar();
+const { t } = useI18n();
+const { inputRules } = useInputRules();
+const { login } = useAuth();
+
+const formRef = useTemplateRef("formRef");
+const photoInputRef = useTemplateRef("photoInputRef");
+
+const token = ref(null);
+const lockedFields = ref([]);
+const photoFile = ref(null);
+const photoPreview = ref(null);
+const photoError = ref(null);
+const positionOptions = ref([]);
+const sectorOptions = ref([]);
+
+const form = ref({
+  registration: null,
+  name: null,
+  cpf: null,
+  email: null,
+  phone: null,
+  position_id: null,
+  sector_id: null,
+  password: null,
+  password_confirmation: null,
+});
+
+const isLocked = (field) => lockedFields.value.includes(field);
+
+const {
+  loading,
+  validationErrors,
+  execute: submitForm,
+} = useSubmitHandler({ formRef });
+
+const onPhotoSelected = (event) => {
+  const file = event.target.files?.[0];
+  event.target.value = "";
+  if (!file) return;
+
+  photoFile.value = file;
+  photoPreview.value = URL.createObjectURL(file);
+  photoError.value = null;
+};
+
+const onSubmit = async () => {
+  if (!photoFile.value) {
+    photoError.value = t("auth.first_access.photo_required");
+    return;
+  }
+
+  const password = form.value.password;
+
+  const user = await submitForm(() =>
+    registerFirstAccess({
+      ...form.value,
+      token: token.value,
+      photo: photoFile.value,
+    }),
+  );
+
+  if (!user) return;
+
+  clearFirstAccessData();
+
+  try {
+    await login(form.value.registration, password);
+    $q.notify({ type: "positive", message: t("auth.first_access.success") });
+    router.push({ name: "CarteirinhaPage" });
+  } catch {
+    $q.notify({ type: "positive", message: t("auth.first_access.success_login") });
+    router.push({ name: "LoginPage" });
+  }
+};
+
+const goBack = () => {
+  clearFirstAccessData();
+  router.push({ name: "LoginPage" });
+};
+
+onBeforeMount(async () => {
+  const data = getFirstAccessData();
+
+  if (!data?.token) {
+    router.replace({ name: "FirstAccessPage" });
+    return;
+  }
+
+  token.value = data.token;
+  form.value.registration = data.registration;
+
+  if (data.user) {
+    const { photo_url, ...fields } = data.user;
+
+    Object.entries(fields).forEach(([key, value]) => {
+      if (value === null || value === "") return;
+      form.value[key] = value;
+      lockedFields.value.push(key);
+    });
+
+    if (photo_url) {
+      photoPreview.value = photo_url;
+    }
+  }
+
+  try {
+    const [positions, sectors] = await Promise.all([
+      getPublicPositions(),
+      getPublicSectors(),
+    ]);
+    positionOptions.value = positions ?? [];
+    sectorOptions.value = sectors ?? [];
+  } catch {
+    $q.notify({ type: "negative", message: t("http.errors.failed") });
+  }
+});
+</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);
+  margin: 16px 0;
+  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: 220px;
+  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, "text-2");
+  font-size: 13px;
+  text-align: center;
+  margin: 0 0 16px;
+}
+
+.password-hint {
+  color: map.get($colors, "text-2");
+  font-size: 12px;
+  margin: 0;
+}
+
+.avatar-wrap {
+  position: relative;
+  display: inline-block;
+}
+
+.first-access-avatar {
+  background: rgba(112, 32, 130, 0.18) !important;
+}
+
+.avatar-img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+}
+
+.avatar-edit-btn {
+  position: absolute;
+  bottom: -2px;
+  right: -2px;
+}
+
+.login-btn {
+  height: 44px;
+  font-size: 15px;
+  font-weight: 600;
+  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;
+  }
+}
+</style>

+ 200 - 0
src/pages/login/FirstAccessPage.vue

@@ -0,0 +1,200 @@
+<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" />
+
+        <p class="login-title">{{ $t("auth.first_access.title") }}</p>
+        <p class="login-description">{{ $t("auth.first_access.registration_hint") }}</p>
+
+        <q-form
+          ref="formRef"
+          class="full-width"
+          autocomplete="off"
+          @submit="onSubmit"
+        >
+          <DefaultInput
+            v-model="form.registration"
+            v-model:error="validationErrors.registration"
+            autofocus
+            color="violet-normal"
+            :label="$t('auth.first_access.registration')"
+            :rules="[inputRules.required]"
+          >
+            <template #append>
+              <q-icon name="mdi-badge-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="forgot-link q-mt-md" @click.prevent="goBack">
+          <q-icon name="mdi-arrow-left" size="12px" class="q-mr-xs" />
+          {{ $t("auth.first_access.back_to_login") }}
+        </a>
+      </div>
+    </div>
+  </q-page>
+</template>
+
+<script setup>
+import { ref, useTemplateRef } from "vue";
+import { useQuasar } from "quasar";
+import { useI18n } from "vue-i18n";
+import { useRouter } from "vue-router";
+import { useInputRules } from "src/composables/useInputRules";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+import { checkFirstAccess } from "src/api/firstAccess";
+import { setFirstAccessData } from "./firstAccessStorage";
+
+import Logo from "src/assets/logo_serprati.svg";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+
+const router = useRouter();
+const $q = useQuasar();
+const { t } = useI18n();
+const { inputRules } = useInputRules();
+
+const formRef = useTemplateRef("formRef");
+const form = ref({ registration: null });
+
+const {
+  loading,
+  validationErrors,
+  execute: submitForm,
+} = useSubmitHandler({ formRef });
+
+const onSubmit = async () => {
+  const payload = await submitForm(() => checkFirstAccess(form.value.registration));
+  if (!payload) return;
+
+  if (payload.status === "already_done") {
+    $q.notify({
+      type: "warning",
+      message: payload.message ?? t("auth.first_access.already_done"),
+      timeout: 6000,
+    });
+    return;
+  }
+
+  setFirstAccessData(payload);
+  router.push({ name: "FirstAccessFormPage" });
+};
+
+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 8px;
+  text-align: center;
+}
+
+.login-description {
+  color: map.get($colors, "text-2");
+  font-size: 13px;
+  text-align: center;
+  margin: 0 0 20px;
+}
+
+.login-btn {
+  height: 44px;
+  font-size: 15px;
+  font-weight: 600;
+  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;
+  }
+}
+</style>

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

@@ -57,6 +57,13 @@
               <q-spinner />
             </template>
           </q-btn>
+          <q-btn
+            class="full-width q-mt-sm login-btn"
+            color="violet-normal"
+            :label="$t('auth.first_access.button')"
+            outline
+            @click="router.push({ name: 'FirstAccessPage' })"
+          />
         </q-form>
 
         <a

+ 19 - 0
src/pages/login/firstAccessStorage.js

@@ -0,0 +1,19 @@
+const STORAGE_KEY = "first_access_data";
+
+export const setFirstAccessData = (payload) => {
+  sessionStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
+};
+
+export const getFirstAccessData = () => {
+  const raw = sessionStorage.getItem(STORAGE_KEY);
+  if (!raw) return null;
+  try {
+    return JSON.parse(raw);
+  } catch {
+    return null;
+  }
+};
+
+export const clearFirstAccessData = () => {
+  sessionStorage.removeItem(STORAGE_KEY);
+};

+ 16 - 2
src/router/index.js

@@ -22,6 +22,17 @@ import { App as CapacitorApp } from "@capacitor/app";
  * with the Router instance.
  */
 
+// Telas acessíveis sem autenticação (login, recuperação de senha e primeiro acesso).
+const PUBLIC_ROUTES = [
+  "LoginPage",
+  "ForgotPasswordPage",
+  "VerifyEmailPage",
+  "VerifyCodePage",
+  "ResetPasswordPage",
+  "FirstAccessPage",
+  "FirstAccessFormPage",
+];
+
 export default defineRouter(function (/* { store, ssrContext } */) {
   const createHistory = process.env.SERVER
     ? createMemoryHistory
@@ -45,11 +56,14 @@ export default defineRouter(function (/* { store, ssrContext } */) {
         await useAuth().refresh();
       } catch {
         refreshed = true;
-        return next({ name: "LoginPage" });
+        if (!PUBLIC_ROUTES.includes(to.name)) {
+          return next({ name: "LoginPage" });
+        }
+        return next();
       }
     }
     if (userStore().accessToken) {
-      if (to.name == "LoginPage") {
+      if (PUBLIC_ROUTES.includes(to.name)) {
         return next({ name: "CarteirinhaPage" });
       }
     }

+ 12 - 0
src/router/routes.js

@@ -47,6 +47,18 @@ const routes = [
         component: () => import("pages/login/LoginPage.vue"),
         meta: { title: "Login" },
       },
+      {
+        path: "primeiro-acesso",
+        name: "FirstAccessPage",
+        component: () => import("pages/login/FirstAccessPage.vue"),
+        meta: { title: "Primeiro Acesso" },
+      },
+      {
+        path: "primeiro-acesso/dados",
+        name: "FirstAccessFormPage",
+        component: () => import("pages/login/FirstAccessFormPage.vue"),
+        meta: { title: "Primeiro Acesso" },
+      },
       {
         path: "recuperar-senha",
         name: "ForgotPasswordPage",