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

77 lines
2.3 KiB
Vue

<script setup>
import { ref, computed, watch } from 'vue'
const props = defineProps({
modelValue: { type: String, default: '' },
disabled: { type: Boolean, default: false },
})
const emit = defineEmits(['update:modelValue'])
const wrapper = ref(null)
const input = ref(props.modelValue)
watch(() => props.modelValue, (v) => { input.value = v })
const apenasDigitos = computed(() => input.value.replace(/\D/g, ''))
const isCnpj = computed(() => apenasDigitos.value.length > 11)
const valorFormatado = computed(() => {
const d = apenasDigitos.value
if (d.length <= 11) {
return d
.replace(/(\d{3})(\d)/, '$1.$2')
.replace(/(\d{3})(\d)/, '$1.$2')
.replace(/(\d{3})(\d{1,2})$/, '$1-$2')
}
return d
.replace(/(\d{2})(\d)/, '$1.$2')
.replace(/(\d{3})(\d)/, '$1.$2')
.replace(/(\d{3})(\d)/, '$1/$2')
.replace(/(\d{4})(\d{1,2})$/, '$1-$2')
})
const placeholder = computed(() => isCnpj.value ? '00.000.000/0000-00' : '000.000.000-00')
function onInput(e) {
const raw = e.target.value.replace(/\D/g, '').slice(0, 14)
input.value = raw
emit('update:modelValue', raw)
}
function focus() {
wrapper.value?.querySelector('input')?.focus()
}
defineExpose({ focus })
</script>
<template>
<div ref="wrapper" class="relative">
<InputText
:value="valorFormatado"
:placeholder="placeholder"
:disabled="disabled"
inputmode="numeric"
autocomplete="username"
class="w-full text-lg tracking-wide"
size="large"
aria-label="CPF ou CNPJ"
aria-required="true"
@input="onInput"
/>
<span
v-if="disabled"
class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-400 font-medium flex items-center gap-1 pointer-events-none"
>
<i class="pi pi-lock text-[10px]" aria-hidden="true" />
{{ isCnpj ? 'CNPJ' : 'CPF' }}
</span>
<span
v-else-if="apenasDigitos.length > 0"
class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-400 font-medium pointer-events-none"
>
{{ isCnpj ? 'CNPJ' : 'CPF' }}
</span>
</div>
</template>