GUILHERME fbb765db8d docs: reescreve CLAUDE.md para refletir a arquitetura Nuxt real
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>
2026-09-25 10:50:29 -03:00

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 applyTemplate fica no onMounted de app.vue e 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 header Host)
  • 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:

  1. Colocar a imagem em src/assets/images/bg-NOME.jpeg
  2. Importar em HomeView/página correspondente e adicionar ao mapa de fundos
  3. Cadastrar o template correspondente em theme.config.js se precisar de cor nova

Dark Mode

  • Classe .app-dark no <html> ativa o dark mode
  • PrimeVue: darkModeSelector: '.app-dark' em primevue.config.js
  • Tailwind: @variant dark (&:where(.app-dark, .app-dark *)) em main.css
  • O widget AccessibilityWidget.vue gerencia a classe e persiste no localStorage

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.vue hoje é só um stub que redireciona pra / ou /portal/painel).
  • Refresh de token: transparente, feito pelo proxy (server/api/proxy/[...path].ts) via getValidAccessToken(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-Dominio injetados
  • Rotas sob /api/v1/publico/** passam sem exigir sessão (isPublico no 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 o core-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 (exceto prefeituraStore.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 — @apply proibido — SCSS só em assets/layout/
  • dark: em toda view nova — ver tabela acima
  • Máximo 300 linhas por página/componente — extrair subcomponentes se ultrapassar
  • Nunca $fetch direto 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' }) (ou navigateTo('/')) para "voltar" — nunca router.back() em fluxos críticos
  • Componentes PrimeVue com dark: manual nos wrappers — PrimeVue já adapta seus internos, mas divs externas precisam de dark: 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 18px
  • a11y-font-xl → fonte 20px
  • a11y-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

  1. Criar src/pages/servicos/minha-tela.vue (o arquivo já é a rota — nada para registrar)
  2. Definir o layout se não for o default (definePageMeta({ layout: '...' }))
  3. Incluir botão voltar (navigateTo('/')) no topo
  4. Adicionar dark: em todos os utilitários Tailwind
  5. Criar/usar um service em src/services/ que chame $fetch via /api/proxy/...
  6. (Opcional) Adicionar no hub de serviços (pages/servicos/index.vue)

Como Adicionar Nova Tela Autenticada

  1. Criar src/pages/portal/minha-tela.vue
  2. definePageMeta({ layout: 'portal' }) — o middleware/auth.ts protege 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)
  3. Criar serviço em src/services/ usando $fetch + /api/proxy/... (nunca rota pública)
  4. Adicionar na navegação do layouts/portal.vue se entrar no menu principal
  5. 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 pathBackground já é populado de fato pelo core-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 em nuxt.config.ts/cada página antes de assumir ao criar uma tela nova.