Substitui o portal Vite+Vue puro por Nuxt 3 com BFF embutido (Nitro server
routes) e fluxo de autenticação Keycloak via token-handler pattern.
Server (BFF):
- server/api/auth/{login,callback,refresh,logout,me}.ts — Keycloak PKCE
- server/api/proxy/[...path].ts — proxy autenticado pro core-api com tenant
- server/utils/{session,keycloak,pkce,redis,tenant,prefeitura}.ts
- server/middleware/csrf.ts — Origin check + header X-Requested-With
Auth (token-handler pattern):
- JWT vive só server-side em Redis; cliente recebe cookie session-id opaco
- Refresh transparente quando access_token expira
- Multi-tenant via hostname → X-Municipio/X-Dominio injetados no proxy
- Realm dedicado: modumfiscal-portal-{env}
Frontend (Nuxt):
- src/pages/** (file-based routing) substitui src/views/
- Plugins SSR: prefeitura (bootstrap pré-hidratação) + auth (hidrata user via /api/auth/me)
- Composables useAuth, useApi, useLoginModal, useFocusLoginInput
- Modal global de login quando middleware /portal/** bloqueia
- Splash overlay no boot esconde flash do preset inicial pro tema dinâmico
- DocumentoInput bloqueia campo quando user autenticado (pré-preenche em certidão/IPTU)
Removidos:
- index.html, vite.config.js, src/main.js, src/router/
- src/config/apiClient.js (substituído por \$fetch via /api/proxy)
- src/services/{auth,prefeitura}Service.js (lógica migrada pra composables/plugins)
- src/mocks/ (não mais usado)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
192 lines
9.3 KiB
Vue
192 lines
9.3 KiB
Vue
<script setup>
|
|
import { ref, reactive, onMounted } from 'vue'
|
|
import { portalService } from '@/services/portalService'
|
|
|
|
definePageMeta({
|
|
layout: 'portal',
|
|
middleware: 'auth',
|
|
})
|
|
|
|
const carregando = ref(true)
|
|
const salvando = ref(false)
|
|
const modoEditar = ref(false)
|
|
const mensagemErro = ref('')
|
|
const mensagemSucesso = ref('')
|
|
|
|
const dados = ref(null)
|
|
const contato = reactive({ email: '', telefone: '', whatsapp: false })
|
|
|
|
onMounted(async () => {
|
|
try {
|
|
const res = await portalService.getDadosCadastrais()
|
|
dados.value = res.data
|
|
contato.email = dados.value?.email ?? ''
|
|
contato.telefone = dados.value?.telefone ?? ''
|
|
contato.whatsapp = dados.value?.whatsapp ?? false
|
|
} catch (e) {
|
|
mensagemErro.value = e?.data?.description ?? 'Não foi possível carregar os dados cadastrais.'
|
|
} finally {
|
|
carregando.value = false
|
|
}
|
|
})
|
|
|
|
async function salvarContato() {
|
|
salvando.value = true
|
|
mensagemErro.value = ''
|
|
mensagemSucesso.value = ''
|
|
try {
|
|
await portalService.atualizarContato({
|
|
email: contato.email,
|
|
telefone: contato.telefone.replace(/\D/g, ''),
|
|
whatsapp: contato.whatsapp,
|
|
})
|
|
dados.value.email = contato.email
|
|
dados.value.telefone = contato.telefone
|
|
dados.value.whatsapp = contato.whatsapp
|
|
mensagemSucesso.value = 'Dados de contato atualizados com sucesso!'
|
|
modoEditar.value = false
|
|
} catch (e) {
|
|
mensagemErro.value = e?.data?.description ?? 'Erro ao salvar. Tente novamente.'
|
|
} finally {
|
|
salvando.value = false
|
|
}
|
|
}
|
|
|
|
function cancelarEdicao() {
|
|
contato.email = dados.value?.email ?? ''
|
|
contato.telefone = dados.value?.telefone ?? ''
|
|
contato.whatsapp = dados.value?.whatsapp ?? false
|
|
modoEditar.value = false
|
|
mensagemErro.value = ''
|
|
}
|
|
|
|
function formatarTelefone(e) {
|
|
const d = e.target.value.replace(/\D/g, '').slice(0, 11)
|
|
contato.telefone = d
|
|
.replace(/(\d{2})(\d)/, '($1) $2')
|
|
.replace(/(\d{5})(\d{1,4})$/, '$1-$2')
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="space-y-6 max-w-2xl">
|
|
|
|
<div>
|
|
<h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100">Dados Cadastrais</h1>
|
|
<p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">Visualize seus dados e mantenha o contato atualizado.</p>
|
|
</div>
|
|
|
|
<div v-if="carregando" class="space-y-4">
|
|
<div v-for="i in 2" :key="i" class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6 space-y-4">
|
|
<div class="h-4 bg-slate-200 dark:bg-slate-700 rounded animate-pulse w-1/4" />
|
|
<div class="space-y-3">
|
|
<div class="h-3.5 bg-slate-200 dark:bg-slate-700 rounded animate-pulse w-3/4" />
|
|
<div class="h-3.5 bg-slate-200 dark:bg-slate-700 rounded animate-pulse w-1/2" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<template v-else-if="dados">
|
|
|
|
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6">
|
|
<p class="text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-4">Identificação</p>
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div>
|
|
<p class="text-xs text-slate-400 dark:text-slate-500">{{ dados.tipoPessoa === 'JURIDICA' ? 'CNPJ' : 'CPF' }}</p>
|
|
<p class="text-sm font-semibold text-slate-800 dark:text-slate-100 mt-0.5 font-mono">{{ dados.documento }}</p>
|
|
</div>
|
|
<div>
|
|
<p class="text-xs text-slate-400 dark:text-slate-500">{{ dados.tipoPessoa === 'JURIDICA' ? 'Razão Social' : 'Nome completo' }}</p>
|
|
<p class="text-sm font-semibold text-slate-800 dark:text-slate-100 mt-0.5">{{ dados.nomeCompleto }}</p>
|
|
</div>
|
|
<div v-if="dados.nomeFantasia">
|
|
<p class="text-xs text-slate-400 dark:text-slate-500">Nome fantasia</p>
|
|
<p class="text-sm font-semibold text-slate-800 dark:text-slate-100 mt-0.5">{{ dados.nomeFantasia }}</p>
|
|
</div>
|
|
<div v-if="dados.dataNascimento">
|
|
<p class="text-xs text-slate-400 dark:text-slate-500">Data de nascimento</p>
|
|
<p class="text-sm font-semibold text-slate-800 dark:text-slate-100 mt-0.5">{{ dados.dataNascimento }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6">
|
|
<p class="text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-4">Endereço</p>
|
|
<p class="text-sm text-slate-800 dark:text-slate-100">
|
|
{{ dados.endereco?.logradouro }}, {{ dados.endereco?.numero }}
|
|
<template v-if="dados.endereco?.complemento"> — {{ dados.endereco.complemento }}</template>
|
|
</p>
|
|
<p class="text-sm text-slate-600 dark:text-slate-300 mt-0.5">
|
|
{{ dados.endereco?.bairro }} — {{ dados.endereco?.cidade }}/{{ dados.endereco?.uf }}
|
|
</p>
|
|
<p class="text-xs text-slate-400 dark:text-slate-500 mt-1">CEP: {{ dados.endereco?.cep }}</p>
|
|
<p class="text-xs text-slate-400 dark:text-slate-500 mt-3">
|
|
Para alterar o endereço, compareça ao setor de atendimento da Prefeitura.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<p class="text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-wide">Contato</p>
|
|
<Button
|
|
v-if="!modoEditar"
|
|
label="Editar"
|
|
icon="pi pi-pencil"
|
|
size="small"
|
|
text
|
|
@click="modoEditar = true"
|
|
/>
|
|
</div>
|
|
|
|
<div v-if="!modoEditar" class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div>
|
|
<p class="text-xs text-slate-400 dark:text-slate-500">E-mail</p>
|
|
<p class="text-sm font-semibold text-slate-800 dark:text-slate-100 mt-0.5">{{ dados.email }}</p>
|
|
</div>
|
|
<div>
|
|
<p class="text-xs text-slate-400 dark:text-slate-500">Telefone</p>
|
|
<p class="text-sm font-semibold text-slate-800 dark:text-slate-100 mt-0.5">
|
|
{{ dados.telefone }}
|
|
<span v-if="dados.whatsapp" class="ml-1.5 text-xs text-emerald-600 dark:text-emerald-400 font-normal">(WhatsApp)</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else class="space-y-4">
|
|
<div>
|
|
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">E-mail</label>
|
|
<InputText v-model="contato.email" type="email" class="w-full" size="large" />
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">Telefone / Celular</label>
|
|
<InputText :value="contato.telefone" class="w-full" size="large" inputmode="numeric" @input="formatarTelefone" />
|
|
</div>
|
|
<div class="flex items-center gap-3 p-4 bg-slate-50 dark:bg-slate-700/50 rounded-xl">
|
|
<Checkbox v-model="contato.whatsapp" :binary="true" input-id="whatsapp-dados" />
|
|
<label for="whatsapp-dados" class="text-sm text-slate-700 dark:text-slate-200 cursor-pointer">
|
|
Este número também recebe WhatsApp
|
|
</label>
|
|
</div>
|
|
<p v-if="mensagemErro" 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" /> {{ mensagemErro }}
|
|
</p>
|
|
<div class="flex gap-3">
|
|
<Button label="Cancelar" severity="secondary" outlined class="flex-1" @click="cancelarEdicao" />
|
|
<Button label="Salvar" icon="pi pi-check" class="flex-1" :loading="salvando" @click="salvarContato" />
|
|
</div>
|
|
</div>
|
|
|
|
<p v-if="mensagemSucesso && !modoEditar" role="status" class="mt-4 text-sm text-emerald-600 dark:text-emerald-400 flex items-center gap-1.5">
|
|
<i class="pi pi-check-circle" aria-hidden="true" /> {{ mensagemSucesso }}
|
|
</p>
|
|
</div>
|
|
|
|
</template>
|
|
|
|
<div v-else-if="mensagemErro" class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-8 text-center">
|
|
<p class="text-sm text-slate-600 dark:text-slate-300">{{ mensagemErro }}</p>
|
|
</div>
|
|
|
|
</div>
|
|
</template>
|