A versao anterior descrevia uma SPA Vue com vue-router/main.js. O projeto e Nuxt 3 de verdade: roteamento por arquivo em src/pages/, autenticacao via padrao Token-Handler/BFF (token nunca chega ao browser, sessao em Redis) em vez do PKCE client-side "pendente" descrito antes, chamadas via $fetch + server/api/proxy (nao existe apiClient/apiClientPublico), variaveis NUXT_* (nao VITE_*). A tabela "O Que Esta Pendente" foi removida: credenciamento, primeiro acesso, certidao, IPTU e avisos ja estao implementados - verificado lendo os services e paginas reais. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
20 KiB
portal-modumfiscal-web — Contexto para IA
Revisado por completo em 2026-09-25. A versão anterior deste arquivo descrevia o projeto como uma SPA Vue com
vue-router/main.js— isso está errado: o projeto é um app Nuxt 3 real (SSR, roteamento por arquivo, BFF de autenticação). Praticamente todo o conteúdo sobre arquitetura, rotas e autenticação foi reescrito após leitura direta do código (nuxt.config.ts,src/pages/,server/,src/plugins/,src/middleware/). A tabela "O Que Está Pendente" da versão anterior também estava obsoleta — os itens que ela listava como pendentes (PKCE, certidão, IPTU, avisos, credenciamento, primeiro acesso) já estão implementados.
Portal público de autoatendimento fiscal do contribuinte. SaaS multi-tenant: cada
prefeitura cliente tem seu próprio subdomínio (tutoia.modumfiscal.com.br) e uma
identidade visual distinta (cor primária + foto de fundo + logo).
Projeto irmão: core-modumfiscal-web (backoffice interno da prefeitura, outro repo).
Backend compartilhado: API REST Spring Boot em core-modumfiscal-api, sempre acessada
através do próprio servidor Nuxt (nunca direto do browser — ver seção de Autenticação).
Stack
| Dependência | Versão | Observação |
|---|---|---|
| Nuxt | 3.14 | ssr: true, srcDir: 'src', serverDir: 'server' |
| Vue | 3.5 | <script setup> obrigatório |
| Vite | 8 (via Nuxt) | Plugin @tailwindcss/vite (sem PostCSS) |
| PrimeVue | 4.5 | Módulo @primevue/nuxt-module, tema Aura, auto-importado |
@primeuix/themes |
2 | updatePreset, updateSurfacePalette, definePreset |
| TailwindCSS | 4 | @import "tailwindcss" no CSS — sem tailwind.config.js |
| Pinia | 3 | Módulos @pinia/nuxt + pinia-plugin-persistedstate/nuxt |
| Axios | não usado | Chamadas HTTP são via $fetch nativo do Nuxt (ver Services) |
| Zod | 4 | Validação de formulários (wizards) |
ioredis |
5 | Sessão + PKCE state (servidor apenas — ver Autenticação) |
jose / jwt-decode |
— | Decodificação de token no servidor |
| TypeScript | 5.6 | Usado em server/ e em parte de src/ (.ts); componentes .vue continuam em JS |
Não existe roteamento client-side manual (vue-router não é dependência direta do projeto) — o roteamento é por arquivo, via src/pages/ (recurso nativo do Nuxt).
Arquitetura Multi-Tenant
requisição chega no servidor Nuxt (SSR)
└── plugins/prefeitura.server.ts (roda em toda request no servidor)
└── tenantFromHost(host) → dominio (ex.: 'tutoia')
└── GET {coreApiUrl}/api/v1/publico/prefeitura/{dominio}
└── prefeituraStore.$patch({ nomePrefeitura, template, pathLogo, pathBackground, ... })
└── plugins/auth.server.ts (roda em toda request no servidor)
└── GET /api/auth/me (usando o cookie de sessão da própria requisição)
└── authStore.setUser(...) — hidrata o estado autenticado ANTES do HTML ser enviado
app.vue (client, onMounted)
└── applyTemplate(prefeitura.template) + applySurface('slate')
→ PrimeVue já está inicializado neste ponto — pode aplicar o preset dinâmico
Os dois plugins (prefeitura.server.ts, auth.server.ts) rodam no servidor, a cada requisição — é assim que o SSR já entrega HTML com o tema e o estado de login corretos, sem flash de conteúdo errado. Não existe um main.js/bootstrap manual — a ordem de inicialização é controlada pelo próprio Nuxt (plugins → app.vue → hidratação client-side).
Por que
applyTemplatefica noonMounteddeapp.vuee não num plugin server-side?updatePreset()do PrimeVue precisa rodar no client, depois que o PrimeVue já foi montado — chamá-la no servidor não tem efeito sobre o CSS enviado ao browser.
Detecção de Tenant
Existe em duas cópias equivalentes (uma para o server, uma para uso client-side), ambas em TypeScript:
server/utils/tenant.ts— usado pelos plugins/rotas de servidor (tenantFromEvent, a partir do headerHost)src/utils/tenant.ts— usado no client quando necessário
tenantFromHost(host) → primeiro subdomínio, minúsculo
→ lista de bloqueio (www, api, admin, test, dev, staging, production, localhost, ...) cai no fallback
→ subdomínio inválido (não bate `/^[a-z0-9-]+$/`) cai no fallback
→ fallback final: 'sistema'
Theming Dinâmico
Templates disponíveis em src/config/theme.config.js:
sistema · tutoia · amber · blue · indigo · violet · emerald · teal · rose
O campo template retornado pela API (ex: "tutoia") determina a paleta primary.
A superfície é sempre slate — não mudar.
Para adicionar novo município com foto de fundo:
- Colocar a imagem em
src/assets/images/bg-NOME.jpeg - Importar em
HomeView/página correspondente e adicionar ao mapa de fundos - Cadastrar o
templatecorrespondente emtheme.config.jsse precisar de cor nova
Dark Mode
- Classe
.app-darkno<html>ativa o dark mode - PrimeVue:
darkModeSelector: '.app-dark'emprimevue.config.js - Tailwind:
@variant dark (&:where(.app-dark, .app-dark *))emmain.css - O widget
AccessibilityWidget.vuegerencia a classe e persiste nolocalStorage
Toda view nova deve incluir dark: nos utilitários Tailwind. Referência rápida:
| Light | Dark |
|---|---|
bg-white |
dark:bg-slate-900 |
bg-slate-50 |
dark:bg-slate-950 |
bg-slate-100 |
dark:bg-slate-800 |
border-slate-200 |
dark:border-slate-700 |
text-slate-800 |
dark:text-slate-100 |
text-slate-600 |
dark:text-slate-300 |
text-slate-500 |
dark:text-slate-400 |
hover:bg-slate-100 |
dark:hover:bg-slate-800 |
Autenticação — padrão Token-Handler (BFF), não PKCE client-side
O token de acesso nunca chega ao browser. Toda a troca OAuth2/PKCE com o Keycloak acontece no servidor Nuxt; o cliente só recebe um cookie de sessão httpOnly opaco.
1. Usuário clica "Entrar" → abre LoginModal.vue (composable useLoginModal)
2. POST /api/auth/login { documento?, returnTo? }
server/api/auth/login.post.ts:
gera PKCE (code_verifier/code_challenge) + state
salva o par em Redis (server/utils/pkce.ts, TTL configurável)
devolve { authUrl } apontando pro Keycloak
3. Browser navega para authUrl (Keycloak)
4. Keycloak redireciona pra GET /api/auth/callback?code=...&state=...
server/api/auth/callback.get.ts:
recupera o PKCE state salvo (consumePkceState)
troca o code por tokens (server-side, com client secret)
cria sessão em Redis (server/utils/session.ts) com access/refresh/id token
seta cookie de sessão httpOnly (setSessionCookie)
redireciona pro returnTo original (padrão: /portal/painel)
5. Toda página/plugin subsequente:
GET /api/auth/me → lê sessão do cookie → devolve só nome/documento/email/roles
authStore (Pinia) guarda SÓ esses metadados — nunca o token
- Middleware de rota (
src/middleware/auth.ts): protege/portal/**. Se não autenticado, abre o modal global de login (não redireciona pra uma página/login— essa página não existe mais como fluxo principal;pages/entrar.vuehoje é só um stub que redireciona pra/ou/portal/painel). - Refresh de token: transparente, feito pelo proxy (
server/api/proxy/[...path].ts) viagetValidAccessToken(sid)— a página nunca lida com expiração de token diretamente. - Logout:
POST /api/auth/logout— invalida a sessão em Redis e limpa o cookie.
Chamada à API — sempre via /api/proxy/**
Não existe apiClient.js/apiClientPublico. Os services usam o $fetch global do Nuxt, sempre contra o proxy do próprio servidor:
// src/services/[dominio]Service.js
const FETCH_HEADERS = { 'X-Requested-With': 'fetch' }
function proxyUrl(path) { return `/api/proxy${path}` }
export const meuService = {
listar(params) {
return $fetch(proxyUrl('/contribuinte/algo'), { headers: FETCH_HEADERS, query: params })
},
}
server/api/proxy/[...path].ts é o único ponto que fala com core-modumfiscal-api:
- Lê a sessão do cookie e busca o access token válido em Redis (refresh automático se expirado)
- Resolve o tenant pelo host e busca
codigoMunicipio(cacheado em Redis) - Encaminha a chamada com
Authorization: Bearer ...+X-Municipio+X-Dominioinjetados - Rotas sob
/api/v1/publico/**passam sem exigir sessão (isPublicono proxy)
Regra: nunca chamar $fetch direto pra URL do core-api num componente/página — sempre via service, sempre via /api/proxy/....
Estrutura de Pastas
src/
├── app.vue ← splash de boot + NuxtLayout/NuxtPage + Toast + LoginModal global
├── assets/
│ ├── main.css Tailwind v4 directives + @variant dark
│ ├── layout/layout.scss Focus ring, skip link, prefers-reduced-motion, classes a11y
│ └── images/ Logos e fotos de fundo (importação estática Vite)
├── components/
│ ├── common/ AppHeader, AppFooter, ServiceCard, AccessibilityWidget
│ └── auth/ DocumentoInput.vue, LoginModal.vue
├── composables/
│ ├── useMotion.js prefers-reduced-motion reativo
│ ├── useAuth.ts wrapper de authStore + chamadas de login/logout
│ └── useLoginModal.ts abre/fecha o LoginModal global, guarda o returnTo
├── config/
│ ├── primevue.config.js Aura preset + locale pt-BR
│ └── theme.config.js Paletas e applyTemplate()/applySurface()
├── layouts/
│ ├── default.vue Header + Footer + RouterView (rotas públicas — NÃO se chama PublicLayout)
│ └── portal.vue Header autenticado com nav + Sair (NÃO se chama PortalLayout)
├── middleware/
│ └── auth.ts Guard de `/portal/**` — abre o LoginModal (ver Autenticação)
├── pages/ ← ROTEAMENTO POR ARQUIVO (Nuxt) — não existe router/index.js
│ ├── index.vue → /
│ ├── entrar.vue → /entrar (stub de redirect — login real é via modal)
│ ├── credenciamento.vue → /credenciamento (wizard, ~6 etapas)
│ ├── primeiro-acesso.vue → /primeiro-acesso (wizard, 3 etapas)
│ ├── servicos/
│ │ ├── index.vue → /servicos
│ │ ├── certidao.vue → /servicos/certidao
│ │ └── iptu.vue → /servicos/iptu
│ └── portal/
│ ├── painel.vue → /portal/painel
│ ├── debitos.vue → /portal/debitos
│ ├── pagamentos.vue → /portal/pagamentos
│ ├── guias.vue → /portal/guias
│ ├── certidoes.vue → /portal/certidoes
│ ├── dados.vue → /portal/dados
│ └── taxas/
│ ├── index.vue → /portal/taxas
│ └── emitir.vue → /portal/taxas/emitir
├── plugins/
│ ├── prefeitura.server.ts Bootstrap do tenant — roda a cada request no servidor
│ └── auth.server.ts Hidrata authStore via /api/auth/me — roda a cada request no servidor
├── services/ avisoService, certidaoService, credenciamentoService,
│ iptuService, portalService, primeiroAcessoService — todos via $fetch + /api/proxy
├── stores/
│ ├── prefeituraStore.js Config da prefeitura (persiste localStorage; Options API por legado)
│ └── authStore.js SÓ metadados do usuário — token nunca fica aqui nem no cliente
└── utils/
└── tenant.ts Espelho client-side de server/utils/tenant.ts
server/
├── api/
│ ├── auth/ login.post.ts, callback.get.ts, me.get.ts, refresh.post.ts, logout.post.ts
│ └── proxy/[...path].ts Único gateway para o core-api (ver seção acima)
├── middleware/
│ └── csrf.ts
└── utils/
├── keycloak.ts buildAuthUrl, exchangeCodeForTokens, userInfoFromAccessToken
├── pkce.ts generatePkce, savePkceState, consumePkceState (Redis)
├── session.ts createSession, getValidAccessToken, setSessionCookie, etc. (Redis)
├── redis.ts cliente ioredis compartilhado
├── prefeitura.ts fetchPrefeituraInfo (cacheado em Redis)
└── tenant.ts tenantFromHost / tenantFromEvent
Rotas
Roteamento por arquivo — criar um .vue em src/pages/ já cria a rota, sem passo de registro manual.
| Path | Arquivo | Layout | Auth |
|---|---|---|---|
/ |
pages/index.vue |
default |
✗ |
/entrar |
pages/entrar.vue |
— | stub de redirect |
/credenciamento |
pages/credenciamento.vue |
default |
✗ |
/primeiro-acesso |
pages/primeiro-acesso.vue |
default |
✗ |
/servicos |
pages/servicos/index.vue |
default |
✗ |
/servicos/certidao |
pages/servicos/certidao.vue |
default |
✗ |
/servicos/iptu |
pages/servicos/iptu.vue |
default |
✗ |
/portal/painel |
pages/portal/painel.vue |
portal |
✓ (middleware/auth.ts) |
/portal/debitos |
pages/portal/debitos.vue |
portal |
✓ |
/portal/pagamentos |
pages/portal/pagamentos.vue |
portal |
✓ |
/portal/guias |
pages/portal/guias.vue |
portal |
✓ |
/portal/certidoes |
pages/portal/certidoes.vue |
portal |
✓ |
/portal/dados |
pages/portal/dados.vue |
portal |
✓ |
/portal/taxas |
pages/portal/taxas/index.vue |
portal |
✓ |
/portal/taxas/emitir |
pages/portal/taxas/emitir.vue |
portal |
✓ |
Não existe mais
/portal/alvaras— o módulo de Alvará foi removido do produto (consolidado em Taxas), consistente com ocore-modumfiscal-api.
Fluxo de Login (modal, não página)
Qualquer página → clique em "Entrar" ou navegação bloqueada por middleware/auth.ts
└── useLoginModal().open(returnTo) → abre LoginModal.vue (renderizado globalmente em app.vue)
└── LoginModal chama POST /api/auth/login → recebe { authUrl } → window.location = authUrl
└── Keycloak autentica → GET /api/auth/callback → sessão criada → redirect pro returnTo
Não existe mais uma página LoginView.vue com captura de CPF/CNPJ em duas etapas — isso foi substituído pelo modal global. pages/entrar.vue existe só como redirecionamento de compatibilidade.
Variáveis de Ambiente
Nuxt usa runtimeConfig (não VITE_*). Chaves reais, definidas em nuxt.config.ts e sobrescritas via .env local ou secret manager em produção:
# .env (GIT IGNORADO — copiar de .env.example e preencher)
NUXT_CORE_API_URL=... # URL do core-modumfiscal-api, usada só no servidor
NUXT_KEYCLOAK_URL=...
NUXT_KEYCLOAK_REALM=...
NUXT_KEYCLOAK_CLIENT_ID=...
NUXT_KEYCLOAK_CLIENT_SECRET=... # client confidential — nunca vai pro bundle client-side
NUXT_REDIS_URL=... # sessão + PKCE state
NUXT_COOKIE_SECRET=... # assina o cookie de sessão — rotacionar em incidente
NUXT_SESSION_TTL_SECONDS=... # default 8h
NUXT_PKCE_TTL_SECONDS=... # default 5min
Todas essas chaves são runtimeConfig server-only (não expostas ao client) — só runtimeConfig.public.appName é pública. .env.production no repo está vazio (template); valores reais só em .env local ou no secret manager do orquestrador.
Regras Críticas de Código
<script setup>obrigatório — Options API proibida (excetoprefeituraStore.js, que usa Options API por compatibilidade com o padrão do core)- PrimeVue auto-importado — não adicionar
import Button from 'primevue/button'manualmente - CSS: TailwindCSS inline no template —
@applyproibido — SCSS só emassets/layout/ dark:em toda view nova — ver tabela acima- Máximo 300 linhas por página/componente — extrair subcomponentes se ultrapassar
- Nunca
$fetchdireto num componente/página apontando pro core-api — sempre via service, sempre via/api/proxy/... - Nunca guardar token no client (localStorage, cookie não-httpOnly, store) — o padrão é Token-Handler/BFF; se uma feature nova parecer exigir isso, é sinal de que deveria virar mais uma rota em
server/api/ router.push({ name: 'index' })(ounavigateTo('/')) para "voltar" — nuncarouter.back()em fluxos críticos- Componentes PrimeVue com
dark:manual nos wrappers — PrimeVue já adapta seus internos, mas divs externas precisam dedark:Tailwind
Acessibilidade
Widget flutuante (AccessibilityWidget.vue) disponível em todas as telas via app.vue.
Persiste preferências no localStorage (chaves: a11y-fonte, a11y-contraste, a11y-escuro).
Classes aplicadas no <html>:
a11y-font-lg→ fonte 18pxa11y-font-xl→ fonte 20pxa11y-contrast→filter: contrast(1.45)app-dark→ dark mode (PrimeVue + Tailwind)
ARIA implementada: skip link, aria-label, aria-current, role="alert", aria-live,
aria-hidden em ícones decorativos, role="contentinfo" no footer.
Como Adicionar Nova Tela Pública
- Criar
src/pages/servicos/minha-tela.vue(o arquivo já é a rota — nada para registrar) - Definir o layout se não for o
default(definePageMeta({ layout: '...' })) - Incluir botão voltar (
navigateTo('/')) no topo - Adicionar
dark:em todos os utilitários Tailwind - Criar/usar um service em
src/services/que chame$fetchvia/api/proxy/... - (Opcional) Adicionar no hub de serviços (
pages/servicos/index.vue)
Como Adicionar Nova Tela Autenticada
- Criar
src/pages/portal/minha-tela.vue definePageMeta({ layout: 'portal' })— omiddleware/auth.tsprotege tudo sob/portal/**automaticamente (confirmar se o middleware está registrado globalmente para o prefixo ou se precisa ser declarado por página antes de assumir)- Criar serviço em
src/services/usando$fetch+/api/proxy/...(nunca rota pública) - Adicionar na navegação do
layouts/portal.vuese entrar no menu principal - Adicionar
dark:em todos os utilitários Tailwind
Comandos
npm run dev # nuxt dev — http://localhost:3000 (ou porta configurada)
npm run build # nuxt build
npm run generate # nuxt generate
npm run preview # nuxt preview
npm run lint # ESLint + fix
Pitfalls Conhecidos
| Erro | Correto |
|---|---|
Assumir SPA com vue-router/main.js |
É Nuxt 3 — roteamento por arquivo em src/pages/, bootstrap via plugins/*.server.ts |
applyTemplate() fora do onMounted de app.vue |
PrimeVue precisa estar inicializado no client primeiro |
Redirecionar pra uma página /login |
Login é o LoginModal.vue global, via useLoginModal() |
| Guardar token de acesso no client | Padrão é Token-Handler — token fica só em Redis, servidor injeta via proxy |
import Button from 'primevue/button' |
Não importar — PrimeVue é auto-importado |
@apply no template |
TailwindCSS inline — @apply só em layout.scss |
Utilitário Tailwind sem dark: |
Sempre adicionar par dark: para qualquer bg/text/border |
$fetch direto pra URL do core-api |
Sempre via service, sempre via /api/proxy/... |
Variável de ambiente VITE_* |
Nuxt usa runtimeConfig, variáveis reais são NUXT_* |
| Path relativo de logo/foto da API | resolverUrl() em plugins/prefeitura.server.ts converte para URL absoluta |
Pontos não totalmente confirmados nesta revisão
- Se
pathBackgroundjá é populado de fato pelocore-modumfiscal-api(o código do plugin já lê o campo da resposta, mas não confirmei o DTO do backend). - Mecanismo exato de como
middleware/auth.tsé aplicado a todas as rotas/portal/**(globalmente vs. por página) — confirmar emnuxt.config.ts/cada página antes de assumir ao criar uma tela nova.