gabrielb 71e1a3f970 feat: portal Nuxt 3 com BFF + autenticação Keycloak (Fase 1)
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>
2026-05-18 20:31:19 -03:00

211 lines
9.0 KiB
Vue

<script setup>
import { ref, computed } from 'vue'
import { useAuth } from '@/composables/useAuth'
import { certidaoService } from '@/services/certidaoService'
const router = useRouter()
const { isAuthenticated, documento: docUsuarioLogado, nomeUsuario } = useAuth()
const documento = ref(isAuthenticated.value ? docUsuarioLogado.value : '')
const tipoCertidao = ref('negativa')
const etapa = ref('formulario')
const carregandoConsulta = ref(false)
const carregandoEmissao = ref(false)
const resultado = ref(null)
const mensagemErro = ref('')
const tiposCertidao = [
{ value: 'negativa', label: 'Certidão Negativa', descricao: 'Confirma que não há débitos pendentes.' },
{ value: 'positiva_efeitos_negativa', label: 'Positiva com Efeitos de Negativa', descricao: 'Débitos com parcelamento em dia ou com exigibilidade suspensa.' },
{ value: 'positiva', label: 'Certidão Positiva', descricao: 'Confirma a existência de débitos.' },
]
const docValido = computed(() => {
const d = documento.value.replace(/\D/g, '')
return d.length === 11 || d.length === 14
})
async function consultar() {
if (!docValido.value) return
carregandoConsulta.value = true
mensagemErro.value = ''
try {
const res = await certidaoService.consultar(documento.value)
resultado.value = res.data
etapa.value = 'resultado'
} catch (e) {
mensagemErro.value = e?.data?.description ?? 'Não foi possível consultar a situação fiscal. Tente novamente.'
} finally {
carregandoConsulta.value = false
}
}
async function emitir() {
carregandoEmissao.value = true
try {
const buf = await certidaoService.emitir(documento.value, tipoCertidao.value)
const url = URL.createObjectURL(new Blob([buf], { type: 'application/pdf' }))
const a = document.createElement('a')
a.href = url
a.download = `certidao-${tipoCertidao.value}-${documento.value}.pdf`
a.click()
URL.revokeObjectURL(url)
} catch {
mensagemErro.value = 'Erro ao gerar o PDF. Tente novamente.'
} finally {
carregandoEmissao.value = false
}
}
function reiniciar() {
documento.value = ''
resultado.value = null
mensagemErro.value = ''
etapa.value = 'formulario'
}
</script>
<template>
<div class="max-w-2xl mx-auto px-4 sm:px-6 py-12">
<button
class="inline-flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200 transition-colors mb-8 py-1"
@click="router.push('/servicos')"
>
<i class="pi pi-arrow-left text-xs" aria-hidden="true" />
Voltar aos serviços
</button>
<div class="flex items-center gap-4 mb-8">
<div class="w-12 h-12 bg-primary/10 dark:bg-primary/20 rounded-xl flex items-center justify-center flex-shrink-0">
<i class="pi pi-file-check text-primary text-xl" aria-hidden="true" />
</div>
<div>
<h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100">Emissão de Certidão</h1>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">Consulte sua situação fiscal e emita a certidão em PDF.</p>
</div>
</div>
<div v-if="etapa === 'formulario'" class="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 p-8 space-y-6">
<div>
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">
CPF ou CNPJ do contribuinte
</label>
<DocumentoInput
v-model="documento"
:disabled="isAuthenticated"
@keyup.enter="consultar"
/>
<p v-if="isAuthenticated" class="mt-1.5 text-xs text-slate-500 dark:text-slate-400 flex items-center gap-1.5">
<i class="pi pi-info-circle text-xs text-primary" aria-hidden="true" />
Logado como <strong class="text-slate-700 dark:text-slate-200">{{ nomeUsuario }}</strong> documento bloqueado para segurança.
</p>
</div>
<div>
<p class="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-3">Tipo de certidão</p>
<div class="space-y-2">
<label
v-for="tipo in tiposCertidao"
:key="tipo.value"
class="flex items-start gap-3 p-4 rounded-xl border cursor-pointer transition-colors"
:class="tipoCertidao === tipo.value
? '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'"
>
<RadioButton v-model="tipoCertidao" :value="tipo.value" :input-id="tipo.value" class="mt-0.5 flex-shrink-0" />
<div>
<p class="text-sm font-semibold text-slate-800 dark:text-slate-100">{{ tipo.label }}</p>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">{{ tipo.descricao }}</p>
</div>
</label>
</div>
</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>
<Button
label="Consultar situação fiscal"
icon="pi pi-search"
class="w-full"
size="large"
:loading="carregandoConsulta"
:disabled="!docValido"
@click="consultar"
/>
</div>
<div v-else-if="etapa === 'resultado'" class="space-y-4">
<div
class="bg-white dark:bg-slate-800 rounded-2xl border p-6"
:class="resultado?.situacao === 'NEGATIVA'
? 'border-emerald-200 dark:border-emerald-700/50'
: 'border-amber-200 dark:border-amber-700/50'"
>
<div class="flex items-center gap-4">
<div
class="w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0"
:class="resultado?.situacao === 'NEGATIVA'
? 'bg-emerald-100 dark:bg-emerald-900/30'
: 'bg-amber-100 dark:bg-amber-900/30'"
>
<i
:class="resultado?.situacao === 'NEGATIVA'
? 'pi pi-check-circle text-emerald-600 dark:text-emerald-400 text-xl'
: 'pi pi-exclamation-triangle text-amber-600 dark:text-amber-400 text-xl'"
aria-hidden="true"
/>
</div>
<div>
<p class="font-bold text-slate-800 dark:text-slate-100">
{{ resultado?.situacao === 'NEGATIVA' ? 'Nada consta' : 'Existem pendências' }}
</p>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
{{ resultado?.nomeContribuinte ?? documento }}
</p>
</div>
</div>
</div>
<div class="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 p-6">
<p class="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1">Certidão a emitir</p>
<p class="font-semibold text-slate-800 dark:text-slate-100">
{{ tiposCertidao.find(t => t.value === tipoCertidao)?.label }}
</p>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-4">
Validade: <span class="font-medium">180 dias a partir da emissão</span>
</p>
</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="Nova consulta"
severity="secondary"
icon="pi pi-arrow-left"
outlined
class="flex-1"
@click="reiniciar"
/>
<Button
label="Emitir PDF"
icon="pi pi-download"
class="flex-1"
:loading="carregandoEmissao"
@click="emitir"
/>
</div>
</div>
</div>
</template>