developer #4

Open
gabrielb wants to merge 20 commits from developer into main
2 changed files with 71 additions and 139 deletions
Showing only changes of commit f3e46cca4c - Show all commits

View File

@ -1,10 +1,8 @@
<script setup>
import { ref, computed } from 'vue'
import { useAuth } from '@/composables/useAuth'
import { primeiroAcessoService } from '@/services/primeiroAcessoService'
const router = useRouter()
const { login } = useAuth()
const etapa = ref(0)
const carregando = ref(false)
@ -12,21 +10,12 @@ const erro = ref('')
const documento = ref('')
const contribuinteNome = ref('')
const canais = ref([])
const canalSelecionado = ref(null)
const codigo = ref('')
const tokenValidacao = ref('')
const senha = ref('')
const senhaConfirm = ref('')
const emailMascarado = ref('')
const docDigitos = computed(() => documento.value.replace(/\D/g, ''))
const docValido = computed(() => docDigitos.value.length === 11 || docDigitos.value.length === 14)
const senhaForte = computed(() => senha.value.length >= 8)
const senhasIguais = computed(() => senha.value === senhaConfirm.value)
const subtitulos = ['Identificação', 'Confirmação', 'Pronto!']
async function identificar() {
if (!docValido.value) return
@ -35,81 +24,37 @@ async function identificar() {
try {
const res = await primeiroAcessoService.verificarDocumento(docDigitos.value)
contribuinteNome.value = res.data.nome
canais.value = res.data.canais
canalSelecionado.value = canais.value[0] ?? null
const canalEmail = res.data.canais?.find(c => c.tipo === 'EMAIL')
emailMascarado.value = canalEmail?.valor ?? ''
etapa.value = 1
} catch (e) {
erro.value = e?.data?.description ?? 'Documento não encontrado ou não habilitado para este fluxo.'
erro.value = e?.data?.description ?? 'Documento não encontrado ou não habilitado para acesso ao portal.'
} finally {
carregando.value = false
}
}
async function enviarCodigo() {
if (!canalSelecionado.value) return
async function solicitar() {
carregando.value = true
erro.value = ''
try {
await primeiroAcessoService.solicitarCodigo(docDigitos.value, canalSelecionado.value.tipo)
await primeiroAcessoService.solicitarAcesso(docDigitos.value)
etapa.value = 2
} catch (e) {
erro.value = e?.data?.description ?? 'Não foi possível enviar o código. Tente novamente.'
erro.value = e?.data?.description ?? 'Não foi possível solicitar o acesso. Tente novamente.'
} finally {
carregando.value = false
}
}
async function validarCodigo() {
if (codigo.value.length < 6) return
carregando.value = true
erro.value = ''
try {
const res = await primeiroAcessoService.validarCodigo(docDigitos.value, codigo.value)
tokenValidacao.value = res.data.token
etapa.value = 3
} catch (e) {
erro.value = e?.data?.description ?? 'Código inválido ou expirado. Solicite um novo.'
} finally {
carregando.value = false
}
}
async function definirSenha() {
if (!senhaForte.value || !senhasIguais.value) return
carregando.value = true
erro.value = ''
try {
await primeiroAcessoService.definirSenha(tokenValidacao.value, senha.value)
etapa.value = 4
} catch (e) {
erro.value = e?.data?.description ?? 'Não foi possível definir a senha. Tente novamente.'
} finally {
carregando.value = false
}
}
async function entrarKeycloak() {
carregando.value = true
erro.value = ''
try {
await login(docDigitos.value, '/portal/painel')
} catch (e) {
carregando.value = false
erro.value = e?.data?.statusMessage ?? 'Não foi possível iniciar o login.'
}
}
const iconeCanal = { EMAIL: 'pi-envelope', SMS: 'pi-mobile', WHATSAPP: 'pi-whatsapp' }
const labelCanal = { EMAIL: 'E-mail', SMS: 'SMS', WHATSAPP: 'WhatsApp' }
</script>
<template>
<div class="min-h-[calc(100vh-8rem)] flex items-center justify-center px-4 py-12">
<div class="w-full max-w-md">
<div v-if="etapa < 4" class="flex items-center justify-center gap-2 mb-8">
<div v-if="etapa < 2" class="flex items-center justify-center gap-2 mb-8">
<div
v-for="i in 4"
v-for="i in 2"
:key="i"
class="h-1.5 rounded-full transition-all duration-300"
:class="[
@ -128,9 +73,7 @@ const labelCanal = { EMAIL: 'E-mail', SMS: 'SMS', WHATSAPP: 'WhatsApp' }
</div>
<div>
<h1 class="text-white font-bold text-base leading-tight">Primeiro Acesso</h1>
<p class="text-white/80 text-xs mt-0.5">
{{ ['Identificação', 'Canal de envio', 'Código de verificação', 'Crie sua senha', 'Pronto!'][etapa] }}
</p>
<p class="text-white/80 text-xs mt-0.5">{{ subtitulos[etapa] }}</p>
</div>
</div>
</div>
@ -139,7 +82,7 @@ const labelCanal = { EMAIL: 'E-mail', SMS: 'SMS', WHATSAPP: 'WhatsApp' }
<template v-if="etapa === 0">
<p class="text-sm text-slate-600 dark:text-slate-300">
Informe seu CPF ou CNPJ para verificarmos seu cadastro e iniciar a criação de senha.
Informe seu CPF ou CNPJ para verificarmos seu cadastro e iniciar a criação do seu acesso ao portal.
</p>
<div>
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">CPF ou CNPJ</label>
@ -148,102 +91,82 @@ const labelCanal = { EMAIL: 'E-mail', SMS: 'SMS', WHATSAPP: 'WhatsApp' }
<p v-if="erro" role="alert" class="text-sm text-red-600 dark:text-red-400 flex items-center gap-1.5">
<i class="pi pi-exclamation-circle" aria-hidden="true" /> {{ erro }}
</p>
<Button label="Continuar" icon="pi pi-arrow-right" icon-pos="right" class="w-full" size="large" :loading="carregando" :disabled="!docValido" @click="identificar" />
<Button
label="Continuar"
icon="pi pi-arrow-right"
icon-pos="right"
class="w-full"
size="large"
:loading="carregando"
:disabled="!docValido || carregando"
@click="identificar"
/>
</template>
<template v-else-if="etapa === 1">
<div>
<p class="text-sm text-slate-600 dark:text-slate-300 mb-1">Olá, <strong class="text-slate-800 dark:text-slate-100">{{ contribuinteNome }}</strong>.</p>
<div class="space-y-3">
<p class="text-sm text-slate-600 dark:text-slate-300">
Enviaremos um código de verificação. Escolha como prefere receber:
Olá, <strong class="text-slate-800 dark:text-slate-100">{{ contribuinteNome }}</strong>.
</p>
<p class="text-sm text-slate-600 dark:text-slate-300">
Vamos criar seu acesso ao portal. Ao confirmar, você receberá um e-mail
<template v-if="emailMascarado">
para <strong class="text-slate-800 dark:text-slate-100">{{ emailMascarado }}</strong>
</template>
com um link para definir sua senha.
</p>
</div>
<div class="space-y-2">
<button
v-for="canal in canais"
:key="canal.tipo"
class="w-full flex items-center gap-3 p-4 rounded-xl border transition-colors text-left"
:class="canalSelecionado?.tipo === canal.tipo
? 'border-primary bg-primary/5 dark:bg-primary/10'
: 'border-slate-200 dark:border-slate-600 hover:border-slate-300 dark:hover:border-slate-500'"
@click="canalSelecionado = canal"
>
<div class="w-9 h-9 rounded-lg flex items-center justify-center flex-shrink-0"
:class="canalSelecionado?.tipo === canal.tipo ? 'bg-primary text-white' : 'bg-slate-100 dark:bg-slate-700 text-slate-500 dark:text-slate-400'">
<i :class="['pi', iconeCanal[canal.tipo] ?? 'pi-send', 'text-sm']" aria-hidden="true" />
</div>
<div>
<p class="font-semibold text-slate-800 dark:text-slate-100 text-sm">{{ labelCanal[canal.tipo] ?? canal.tipo }}</p>
<p class="text-xs text-slate-500 dark:text-slate-400">{{ canal.valor }}</p>
</div>
</button>
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl p-4 flex gap-3">
<i class="pi pi-envelope text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" aria-hidden="true" />
<p class="text-sm text-blue-700 dark:text-blue-300">
O link de acesso é enviado pelo Keycloak e expira em 24 horas. Verifique também a caixa de spam.
</p>
</div>
<p v-if="erro" role="alert" class="text-sm text-red-600 dark:text-red-400 flex items-center gap-1.5">
<i class="pi pi-exclamation-circle" aria-hidden="true" /> {{ erro }}
</p>
<div class="flex gap-3">
<Button label="Voltar" severity="secondary" outlined class="flex-1" @click="etapa = 0" />
<Button label="Enviar código" icon="pi pi-send" class="flex-1" :loading="carregando" :disabled="!canalSelecionado" @click="enviarCodigo" />
<Button label="Voltar" severity="secondary" outlined class="flex-1" :disabled="carregando" @click="etapa = 0" />
<Button
label="Solicitar acesso"
icon="pi pi-send"
class="flex-1"
:loading="carregando"
:disabled="carregando"
@click="solicitar"
/>
</div>
</template>
<template v-else-if="etapa === 2">
<p class="text-sm text-slate-600 dark:text-slate-300">
Enviamos um código de 6 dígitos para
<strong class="text-slate-800 dark:text-slate-100">{{ canalSelecionado?.valor }}</strong>.
Ele expira em 10 minutos.
</p>
<div>
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">Código de verificação</label>
<InputOtp v-model="codigo" :length="6" class="justify-center gap-2" integer-only @keyup.enter="validarCodigo" />
</div>
<p v-if="erro" role="alert" class="text-sm text-red-600 dark:text-red-400 flex items-center gap-1.5">
<i class="pi pi-exclamation-circle" aria-hidden="true" /> {{ erro }}
</p>
<Button label="Verificar código" class="w-full" size="large" :loading="carregando" :disabled="codigo.length < 6" @click="validarCodigo" />
<button class="w-full text-center text-sm text-primary hover:underline" @click="enviarCodigo">
Não recebi o código reenviar
</button>
</template>
<template v-else-if="etapa === 3">
<p class="text-sm text-slate-600 dark:text-slate-300">
Crie uma senha segura com pelo menos 8 caracteres.
</p>
<div class="space-y-4">
<div>
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">Nova senha</label>
<Password v-model="senha" :feedback="true" toggle-mask placeholder="Mínimo 8 caracteres" class="w-full" input-class="w-full" size="large" />
</div>
<div>
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">Confirmar senha</label>
<Password v-model="senhaConfirm" :feedback="false" toggle-mask placeholder="Repita a senha" class="w-full" input-class="w-full" size="large" :invalid="senhaConfirm.length > 0 && !senhasIguais" />
<p v-if="senhaConfirm.length > 0 && !senhasIguais" class="mt-1 text-xs text-red-600 dark:text-red-400">As senhas não coincidem.</p>
</div>
</div>
<p v-if="erro" role="alert" class="text-sm text-red-600 dark:text-red-400 flex items-center gap-1.5">
<i class="pi pi-exclamation-circle" aria-hidden="true" /> {{ erro }}
</p>
<Button label="Criar senha" icon="pi pi-check" class="w-full" size="large" :loading="carregando" :disabled="!senhaForte || !senhasIguais" @click="definirSenha" />
</template>
<template v-else>
<div class="text-center py-4">
<div class="w-16 h-16 bg-emerald-100 dark:bg-emerald-900/30 rounded-2xl flex items-center justify-center mx-auto mb-4">
<i class="pi pi-check-circle text-emerald-600 dark:text-emerald-400 text-3xl" aria-hidden="true" />
<i class="pi pi-envelope-open text-emerald-600 dark:text-emerald-400 text-3xl" aria-hidden="true" />
</div>
<h2 class="text-lg font-bold text-slate-800 dark:text-slate-100 mb-2">Senha criada com sucesso!</h2>
<h2 class="text-lg font-bold text-slate-800 dark:text-slate-100 mb-2">E-mail enviado!</h2>
<p class="text-sm text-slate-500 dark:text-slate-400">
Você pode acessar o portal com seu CPF/CNPJ e a nova senha.
Acesse o e-mail cadastrado e clique no link para definir sua senha e acessar o portal.
</p>
<p v-if="emailMascarado" class="mt-2 text-sm font-medium text-slate-700 dark:text-slate-300">
{{ emailMascarado }}
</p>
</div>
<Button label="Ir para o login" icon="pi pi-sign-in" class="w-full" size="large" :loading="carregando" @click="entrarKeycloak" />
<Button
label="Ir para o login"
icon="pi pi-sign-in"
class="w-full"
size="large"
@click="router.push('/entrar')"
/>
</template>
</div>
</div>
<div v-if="etapa < 4" class="text-center mt-4">
<div v-if="etapa < 2" class="text-center mt-4">
<button
class="inline-flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 transition-colors py-3 px-4 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800"
@click="router.push('/')"

View File

@ -12,6 +12,15 @@ export const primeiroAcessoService = {
})
},
solicitarAcesso(documento) {
return $fetch(proxyUrl('/publico/primeiro-acesso/solicitar'), {
method: 'POST',
headers: FETCH_HEADERS,
body: { documento },
})
},
// TODO Opção A (futuro — fluxo OTP para verificação de identidade antes de criar conta)
solicitarCodigo(documento, canal) {
return $fetch(proxyUrl('/publico/primeiro-acesso/codigo'), {
method: 'POST',