feat/nuxt-bff #1

Merged
gabrielb merged 3 commits from feat/nuxt-bff into developer 2026-05-19 00:22:54 +00:00
72 changed files with 11070 additions and 1785 deletions

View File

@ -1,6 +1,3 @@
VITE_KEYCLOAK_URL= # Arquivo template, mantido no repositório vazio por convenção.
VITE_KEYCLOAK_REALM= # Para rodar o portal em dev, copie .env.example para .env e preencha os valores reais.
VITE_KEYCLOAK_CLIENT_ID= # .env é ignorado pelo git.
VITE_API_URL=
# Ativar mock: copie esta linha para .env.development.local e defina como true
VITE_USE_MOCK=false

View File

@ -1,14 +1,26 @@
# Copie este arquivo para .env.development.local e preencha os valores. # Copie este arquivo para .env e preencha os valores.
# O arquivo .local é ignorado pelo git — nunca commitar credenciais reais. # .env é ignorado pelo git — nunca commitar credenciais reais neste arquivo de exemplo.
#
# Em produção, popule essas variáveis via secret manager do orquestrador (Docker, K8s, etc.).
# URL base da API REST # ─── Core API ────────────────────────────────────────────────────────────────
VITE_API_URL=https://sistema.modumfiscal.com.br # URL base da API REST consumida pelo BFF (server-side apenas).
NUXT_CORE_API_URL=https://sistema.modumfiscal.com.br
# Keycloak # ─── Keycloak (realm dedicado do portal público) ─────────────────────────────
VITE_KEYCLOAK_URL=https://keycloakprod.modumfiscal.com.br NUXT_KEYCLOAK_URL=https://keycloakprod.modumfiscal.com.br
VITE_KEYCLOAK_REALM=modumfiscal-dev NUXT_KEYCLOAK_REALM=modumfiscal-portal-dev
VITE_KEYCLOAK_CLIENT_ID=portal-modumfiscal-web NUXT_KEYCLOAK_CLIENT_ID=portal-modumfiscal-bff
# Client confidential — gere no Keycloak (Clients → Credentials → Client secret).
NUXT_KEYCLOAK_CLIENT_SECRET=SUBSTITUIR_PELO_VALOR_REAL_NO_ENV_LOCAL
# Mock — define como true para rodar sem backend (sessão e dados falsos) # ─── Sessão (Token-handler pattern) ──────────────────────────────────────────
# Quando ativo, o bootstrapPrefeitura é pulado e o interceptor de mock é carregado. # Redis para armazenar tokens server-side e PKCE state.
VITE_USE_MOCK=false NUXT_REDIS_URL=redis://localhost:6379
# Segredo para derivar o cookie de sessão (mín. 32 chars; rotacionar em incidente).
# Gere com: openssl rand -base64 32 OU node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
NUXT_COOKIE_SECRET=GERE_LOCALMENTE_NUNCA_COMMITAR_SECRET_REAL
# TTL da sessão em segundos (default: 8h = 28800).
NUXT_SESSION_TTL_SECONDS=28800
# TTL do PKCE state em segundos (default: 5min = 300).
NUXT_PKCE_TTL_SECONDS=300

View File

@ -0,0 +1,86 @@
name: Dev Build & Deploy Portal
on:
push:
branches:
- developer
# Variáveis necessárias no Gitea (Settings → Variables):
# DEV_NUXT_KEYCLOAK_URL ex: https://keycloakprod.modumfiscal.com.br
# DEV_NUXT_KEYCLOAK_REALM ex: modumfiscal-portal-dev
# DEV_NUXT_KEYCLOAK_CLIENT_ID ex: portal-modumfiscal-bff
# DEV_NUXT_CORE_API_URL ex: https://sistema.modumfiscal.com.br
# DEV_NUXT_REDIS_URL ex: redis://portal-redis:6379
#
# Secrets necessários (Settings → Secrets):
# REGISTRY_USER
# REGISTRY_PASSWORD
# NUXT_KEYCLOAK_CLIENT_SECRET
# NUXT_COOKIE_SECRET gere com: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
jobs:
build-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Define version
id: version
run: |
VERSION=$(date +'%Y.%m.%d.%H%M')
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Login registry
env:
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
run: |
echo "$REGISTRY_PASSWORD" | docker login git.modumsolucao.com.br \
-u "$REGISTRY_USER" \
--password-stdin
- name: Build Docker image
run: |
docker build \
--memory=3g \
-t git.modumsolucao.com.br/modumsolucao/portal-modumfiscal-web:latest \
-t git.modumsolucao.com.br/modumsolucao/portal-modumfiscal-web:${{ steps.version.outputs.version }} \
.
- name: Push image
run: |
docker push git.modumsolucao.com.br/modumsolucao/portal-modumfiscal-web:latest
docker push git.modumsolucao.com.br/modumsolucao/portal-modumfiscal-web:${{ steps.version.outputs.version }}
- name: Deploy DEV
env:
NUXT_KEYCLOAK_URL: ${{ vars.DEV_NUXT_KEYCLOAK_URL }}
NUXT_KEYCLOAK_REALM: ${{ vars.DEV_NUXT_KEYCLOAK_REALM }}
NUXT_KEYCLOAK_CLIENT_ID: ${{ vars.DEV_NUXT_KEYCLOAK_CLIENT_ID }}
NUXT_KEYCLOAK_CLIENT_SECRET: ${{ secrets.NUXT_KEYCLOAK_CLIENT_SECRET }}
NUXT_CORE_API_URL: ${{ vars.DEV_NUXT_CORE_API_URL }}
NUXT_REDIS_URL: ${{ vars.DEV_NUXT_REDIS_URL }}
NUXT_COOKIE_SECRET: ${{ secrets.NUXT_COOKIE_SECRET }}
IMAGE_VERSION: ${{ steps.version.outputs.version }}
run: |
docker service update \
--image git.modumsolucao.com.br/modumsolucao/portal-modumfiscal-web:$IMAGE_VERSION \
--env-add NUXT_KEYCLOAK_URL="$NUXT_KEYCLOAK_URL" \
--env-add NUXT_KEYCLOAK_REALM="$NUXT_KEYCLOAK_REALM" \
--env-add NUXT_KEYCLOAK_CLIENT_ID="$NUXT_KEYCLOAK_CLIENT_ID" \
--env-add NUXT_KEYCLOAK_CLIENT_SECRET="$NUXT_KEYCLOAK_CLIENT_SECRET" \
--env-add NUXT_CORE_API_URL="$NUXT_CORE_API_URL" \
--env-add NUXT_REDIS_URL="$NUXT_REDIS_URL" \
--env-add NUXT_COOKIE_SECRET="$NUXT_COOKIE_SECRET" \
--with-registry-auth \
app_portal-modumfiscal-web
- name: Cleanup old images
run: |
IMAGES_TO_DELETE=$(docker images "git.modumsolucao.com.br/modumsolucao/portal-modumfiscal-web" --format "{{.ID}}" | tail -n +3)
if [ -n "$IMAGES_TO_DELETE" ]; then
echo "$IMAGES_TO_DELETE" | xargs -I {} docker rmi -f {} || true
fi
docker image prune -f

View File

@ -0,0 +1,102 @@
name: Prod Build & Deploy Portal
on:
push:
branches:
- main
# Variáveis necessárias no Gitea (Settings → Variables):
# PROD_NUXT_KEYCLOAK_URL ex: https://keycloakprod.modumfiscal.com.br
# PROD_NUXT_KEYCLOAK_REALM ex: modumfiscal-portal-prod
# PROD_NUXT_KEYCLOAK_CLIENT_ID ex: portal-modumfiscal-bff
# PROD_NUXT_CORE_API_URL ex: https://sistema.modumfiscal.com.br
# PROD_NUXT_REDIS_URL ex: redis://portal-redis:6379
#
# Secrets necessários (Settings → Secrets):
# REGISTRY_USER
# REGISTRY_PASSWORD
# PROD_NUXT_KEYCLOAK_CLIENT_SECRET
# PROD_NUXT_COOKIE_SECRET
jobs:
build:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Define version
id: version
run: |
VERSION=$(date +'%Y.%m.%d.%H%M')
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Login registry
env:
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
run: |
echo "$REGISTRY_PASSWORD" | docker login git.modumsolucao.com.br \
-u "$REGISTRY_USER" \
--password-stdin
- name: Build Docker image
run: |
docker build \
--memory=3g \
-t git.modumsolucao.com.br/modumsolucao/portal-modumfiscal-web:prod-latest \
-t git.modumsolucao.com.br/modumsolucao/portal-modumfiscal-web:prod-${{ steps.version.outputs.version }} \
.
- name: Push image
run: |
docker push git.modumsolucao.com.br/modumsolucao/portal-modumfiscal-web:prod-latest
docker push git.modumsolucao.com.br/modumsolucao/portal-modumfiscal-web:prod-${{ steps.version.outputs.version }}
- name: Cleanup old images
run: |
IMAGES_TO_DELETE=$(docker images "git.modumsolucao.com.br/modumsolucao/portal-modumfiscal-web" --format "{{.ID}}" | tail -n +3)
if [ -n "$IMAGES_TO_DELETE" ]; then
echo "$IMAGES_TO_DELETE" | xargs -I {} docker rmi -f {} || true
fi
docker image prune -f
deploy:
runs-on: prod
needs: build
steps:
- name: Login registry
env:
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
run: |
echo "$REGISTRY_PASSWORD" | docker login git.modumsolucao.com.br \
-u "$REGISTRY_USER" \
--password-stdin
- name: Deploy PROD
env:
NUXT_KEYCLOAK_URL: ${{ vars.PROD_NUXT_KEYCLOAK_URL }}
NUXT_KEYCLOAK_REALM: ${{ vars.PROD_NUXT_KEYCLOAK_REALM }}
NUXT_KEYCLOAK_CLIENT_ID: ${{ vars.PROD_NUXT_KEYCLOAK_CLIENT_ID }}
NUXT_KEYCLOAK_CLIENT_SECRET: ${{ secrets.PROD_NUXT_KEYCLOAK_CLIENT_SECRET }}
NUXT_CORE_API_URL: ${{ vars.PROD_NUXT_CORE_API_URL }}
NUXT_REDIS_URL: ${{ vars.PROD_NUXT_REDIS_URL }}
NUXT_COOKIE_SECRET: ${{ secrets.PROD_NUXT_COOKIE_SECRET }}
IMAGE_VERSION: ${{ needs.build.outputs.version }}
run: |
docker service update \
--image git.modumsolucao.com.br/modumsolucao/portal-modumfiscal-web:prod-$IMAGE_VERSION \
--env-add NUXT_KEYCLOAK_URL="$NUXT_KEYCLOAK_URL" \
--env-add NUXT_KEYCLOAK_REALM="$NUXT_KEYCLOAK_REALM" \
--env-add NUXT_KEYCLOAK_CLIENT_ID="$NUXT_KEYCLOAK_CLIENT_ID" \
--env-add NUXT_KEYCLOAK_CLIENT_SECRET="$NUXT_KEYCLOAK_CLIENT_SECRET" \
--env-add NUXT_CORE_API_URL="$NUXT_CORE_API_URL" \
--env-add NUXT_REDIS_URL="$NUXT_REDIS_URL" \
--env-add NUXT_COOKIE_SECRET="$NUXT_COOKIE_SECRET" \
--with-registry-auth \
app_portal-modumfiscal-web

14
.gitignore vendored
View File

@ -7,16 +7,30 @@ yarn-error.log*
pnpm-debug.log* pnpm-debug.log*
lerna-debug.log* lerna-debug.log*
# Dependencies
node_modules node_modules
# Build artifacts (Vite legado — pode ser removido após migração)
dist dist
dist-ssr dist-ssr
# Nuxt
.nuxt
.output
.nitro
.cache
# Local env files
*.local *.local
.env
.env.*.local
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json
.idea .idea
.DS_Store .DS_Store
.claude
*.suo *.suo
*.ntvs* *.ntvs*
*.njsproj *.njsproj

20
Dockerfile Normal file
View File

@ -0,0 +1,20 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage — apenas o .output do Nuxt (SSR via Node.js)
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NUXT_HOST=0.0.0.0
ENV NUXT_PORT=3000
COPY --from=builder /app/.output ./
EXPOSE 3000
CMD ["node", "server/index.mjs"]

18
docker-compose.yml Normal file
View File

@ -0,0 +1,18 @@
services:
redis:
image: redis:7-alpine
container_name: portal-redis
ports:
- "6379:6379"
restart: unless-stopped
volumes:
- portal_redis_data:/data
command: redis-server --appendonly yes
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
volumes:
portal_redis_data:

View File

@ -1,16 +0,0 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Portal do Contribuinte</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&display=swap" rel="stylesheet" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

100
nuxt.config.ts Normal file
View File

@ -0,0 +1,100 @@
import { defineNuxtConfig } from 'nuxt/config'
import tailwindcss from '@tailwindcss/vite'
import Aura from '@primeuix/themes/aura'
import { definePreset } from '@primeuix/themes'
// Preset inicial com paleta `blue` — evita flash verde (Aura default = emerald) no SSR/boot.
// O tema dinâmico da prefeitura (via theme.config.js / applyTemplate) sobrescreve isso
// no client-side assim que o store é hidratado.
const InitialPreset = definePreset(Aura, {
semantic: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
950: '#172554',
},
},
})
export default defineNuxtConfig({
compatibilityDate: '2025-10-01',
ssr: true,
srcDir: 'src',
serverDir: 'server',
devtools: { enabled: true },
modules: [
'@pinia/nuxt',
'pinia-plugin-persistedstate/nuxt',
'@primevue/nuxt-module',
],
// Auto-import de components sem prefix de diretório (AccessibilityWidget em vez de CommonAccessibilityWidget).
components: [
{ path: '~/components', pathPrefix: false },
],
css: [
'~/assets/main.css',
'~/assets/layout/layout.scss',
],
vite: {
plugins: [tailwindcss()],
},
primevue: {
options: {
theme: {
preset: InitialPreset,
options: { darkModeSelector: '.app-dark' },
},
},
autoImport: true,
},
runtimeConfig: {
// Servidor only — disponível em useRuntimeConfig() no server/
keycloakUrl: '',
keycloakRealm: '',
keycloakClientId: '',
keycloakClientSecret: '',
coreApiUrl: '',
redisUrl: '',
cookieSecret: '',
sessionTtlSeconds: 28800,
pkceTtlSeconds: 300,
public: {
// Disponível no cliente — sem segredos
appName: 'Portal Modum Fiscal',
},
},
app: {
head: {
htmlAttrs: { lang: 'pt-BR' },
title: 'Portal do Contribuinte',
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
],
link: [
{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
],
},
pageTransition: { name: 'page', mode: 'out-in' },
},
typescript: {
strict: false,
},
})

9703
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,18 +1,24 @@
{ {
"name": "portal-modumfiscal-web", "name": "portal-modumfiscal-web",
"private": true, "private": true,
"version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "build": "nuxt build",
"build": "vite build", "dev": "nuxt dev",
"preview": "vite preview", "generate": "nuxt generate",
"lint": "eslint --fix . --ext .vue,.js --ignore-path .gitignore" "preview": "nuxt preview",
"postinstall": "nuxt prepare",
"lint": "eslint --fix . --ext .vue,.js,.ts --ignore-path .gitignore"
}, },
"dependencies": { "dependencies": {
"@pinia/nuxt": "^0.11.2",
"@primeuix/themes": "^2.0.3", "@primeuix/themes": "^2.0.3",
"@primevue/nuxt-module": "^4.5.5",
"axios": "^1.16.1", "axios": "^1.16.1",
"ioredis": "^5.4.1",
"jose": "^5.9.6",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"nuxt": "^3.14.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1", "pinia-plugin-persistedstate": "^4.7.1",
"pkce-challenge": "^6.0.0", "pkce-challenge": "^6.0.0",
@ -20,21 +26,14 @@
"primevue": "^4.5.5", "primevue": "^4.5.5",
"tailwindcss-primeui": "^0.6.1", "tailwindcss-primeui": "^0.6.1",
"vue": "^3.5.34", "vue": "^3.5.34",
"vue-router": "^5.0.7",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@primevue/auto-import-resolver": "^4.5.5",
"@rushstack/eslint-patch": "^1.16.1",
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.0",
"@vitejs/plugin-vue": "^6.0.6",
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^10.2.0",
"autoprefixer": "^10.5.0",
"eslint-plugin-vue": "^10.9.1", "eslint-plugin-vue": "^10.9.1",
"postcss": "^8.5.14",
"sass": "^1.99.0", "sass": "^1.99.0",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.0",
"unplugin-vue-components": "^32.0.0", "typescript": "^5.6.3"
"vite": "^8.0.12"
} }
} }

View File

@ -0,0 +1,40 @@
export default defineEventHandler(async (event) => {
const { code, state, error, error_description } = getQuery(event) as Record<string, string | undefined>
if (error) {
console.warn('[auth/callback] Keycloak retornou erro:', error, error_description)
return sendRedirect(event, `/?auth_error=${encodeURIComponent(error)}`, 302)
}
if (!code || !state) {
return sendRedirect(event, '/?auth_error=missing_params', 302)
}
const pkceState = await consumePkceState(state)
if (!pkceState) {
return sendRedirect(event, '/?auth_error=invalid_state', 302)
}
try {
const tokens = await exchangeCodeForTokens({
code,
codeVerifier: pkceState.codeVerifier,
redirectUri: callbackUrlFromEvent(event),
})
const userInfo = userInfoFromAccessToken(tokens.access_token)
const sid = await createSession({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
idToken: tokens.id_token,
accessTokenExpiresAt: Date.now() + tokens.expires_in * 1000,
userInfo,
})
setSessionCookie(event, sid)
return sendRedirect(event, pkceState.returnTo, 302)
} catch (err) {
console.error('[auth/callback] exchange falhou:', (err as Error).message)
return sendRedirect(event, '/?auth_error=exchange_failed', 302)
}
})

View File

@ -0,0 +1,32 @@
import { z } from 'zod'
const bodySchema = z.object({
documento: z.string().trim().min(11).max(20).optional(),
returnTo: z.string().startsWith('/').max(200).optional(),
})
export default defineEventHandler(async (event) => {
const body = await readValidatedBody(event, bodySchema.safeParse)
if (!body.success) {
throw createError({ statusCode: 400, statusMessage: 'Body inválido' })
}
const { codeVerifier, codeChallenge, state } = await generatePkce()
const returnTo = body.data.returnTo ?? '/portal/painel'
await savePkceState(state, {
codeVerifier,
returnTo,
createdAt: Date.now(),
})
const redirectUri = callbackUrlFromEvent(event)
const authUrl = buildAuthUrl({
codeChallenge,
state,
redirectUri,
loginHint: body.data.documento?.replace(/\D/g, ''),
})
return { authUrl }
})

View File

@ -0,0 +1,22 @@
export default defineEventHandler(async (event) => {
const sid = readSessionCookie(event)
const session = await readSession(sid)
await deleteSession(sid)
clearSessionCookie(event)
if (!session) {
return { logoutUrl: '/' }
}
const host = getRequestHost(event, { xForwardedHost: true })
const proto = getRequestProtocol(event, { xForwardedProto: true })
const postLogoutRedirectUri = `${proto}://${host}/`
const logoutUrl = buildLogoutUrl({
idTokenHint: session.idToken,
postLogoutRedirectUri,
})
return { logoutUrl }
})

16
server/api/auth/me.get.ts Normal file
View File

@ -0,0 +1,16 @@
export default defineEventHandler(async (event) => {
const sid = readSessionCookie(event)
const session = await readSession(sid)
if (!session) {
throw createError({ statusCode: 401, statusMessage: 'Sem sessão' })
}
// Devolve só metadados — tokens nunca saem do servidor
return {
name: session.userInfo.name ?? '',
documento: session.userInfo.preferred_username ?? '',
email: session.userInfo.email ?? '',
roles: session.userInfo.realm_roles ?? [],
}
})

View File

@ -0,0 +1,14 @@
export default defineEventHandler(async (event) => {
const sid = readSessionCookie(event)
if (!sid) {
throw createError({ statusCode: 401, statusMessage: 'Sem sessão' })
}
const token = await getValidAccessToken(sid)
if (!token) {
clearSessionCookie(event)
throw createError({ statusCode: 401, statusMessage: 'Sessão expirada' })
}
return { ok: true }
})

View File

@ -0,0 +1,72 @@
/**
* Proxy genérico para o core-api.
*
* Fluxo:
* 1. cookie de sessão busca tokens em Redis (refresh transparente se expirado)
* 2. Resolve o tenant (dominio) a partir do hostname
* 3. Busca codigoMunicipio via /publico/prefeitura/{dominio} (cacheado em Redis)
* 4. Forward para core-api com Authorization + X-Municipio + X-Dominio
*
* Bypass: rotas /api/v1/publico/** podem ser acessadas sem sessão.
*/
export default defineEventHandler(async (event) => {
const path = getRouterParam(event, 'path') ?? ''
const isPublico = path.startsWith('publico/')
let accessToken: string | null = null
if (!isPublico) {
const sid = readSessionCookie(event)
if (!sid) {
throw createError({ statusCode: 401, statusMessage: 'Sem sessão' })
}
accessToken = await getValidAccessToken(sid)
if (!accessToken) {
clearSessionCookie(event)
throw createError({ statusCode: 401, statusMessage: 'Sessão expirada' })
}
}
const dominio = tenantFromEvent(event)
const prefeitura = await fetchPrefeituraInfo(dominio)
if (!prefeitura) {
throw createError({ statusCode: 400, statusMessage: `Tenant '${dominio}' não encontrado` })
}
const cfg = useRuntimeConfig()
const url = `${cfg.coreApiUrl}/api/v1/${path}`
const query = getQuery(event)
const method = event.method.toUpperCase()
const body = ['GET', 'HEAD'].includes(method) ? undefined : await readRawBody(event)
const headers: Record<string, string> = {
'X-Municipio': String(prefeitura.codigoMunicipio),
'X-Dominio': prefeitura.dominio,
}
if (accessToken) headers.Authorization = `Bearer ${accessToken}`
const contentType = getHeader(event, 'content-type')
if (contentType) headers['Content-Type'] = contentType
try {
const res = await $fetch.raw(url, {
method: method as 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
query,
body,
headers,
responseType: 'stream',
})
setResponseStatus(event, res.status)
for (const [name, value] of res.headers.entries()) {
if (name === 'transfer-encoding') continue
setResponseHeader(event, name, value)
}
return res._data
} catch (err: unknown) {
const fetchErr = err as { response?: { status?: number; _data?: unknown } }
if (fetchErr.response) {
setResponseStatus(event, fetchErr.response.status ?? 500)
return fetchErr.response._data
}
throw err
}
})

45
server/middleware/csrf.ts Normal file
View File

@ -0,0 +1,45 @@
/**
* CSRF defense para o BFF.
*
* Aplicada apenas em rotas `/api/**` com métodos mutating (POST/PUT/PATCH/DELETE).
*
* Dupla camada:
* 1. Origin/Referer check sender precisa bater com o host da requisição
* 2. Header custom `X-Requested-With: fetch` força preflight CORS em cross-origin,
* o que o browser bloqueia antes mesmo de chegar aqui (sem CORS permissivo configurado)
*
* Bypass: `/api/auth/callback` (GET externo do Keycloak não é mutating mesmo)
*/
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
export default defineEventHandler((event) => {
const url = getRequestURL(event)
if (!url.pathname.startsWith('/api/')) return
const method = event.method.toUpperCase()
if (SAFE_METHODS.has(method)) return
// 1. Origin/Referer precisa casar com host
const host = getRequestHost(event, { xForwardedHost: true })
const origin = getRequestHeader(event, 'origin')
const referer = getRequestHeader(event, 'referer')
const sender = origin ?? referer
if (!sender) {
throw createError({ statusCode: 403, statusMessage: 'CSRF: Origin/Referer obrigatório' })
}
try {
const senderHost = new URL(sender).host
if (senderHost !== host) {
throw createError({ statusCode: 403, statusMessage: 'CSRF: Origin não confiável' })
}
} catch {
throw createError({ statusCode: 403, statusMessage: 'CSRF: Origin inválido' })
}
// 2. Header custom — sem CORS permissivo, browser bloqueia cross-origin
const requestedWith = getRequestHeader(event, 'x-requested-with')
if (requestedWith !== 'fetch') {
throw createError({ statusCode: 403, statusMessage: 'CSRF: header X-Requested-With ausente' })
}
})

103
server/utils/keycloak.ts Normal file
View File

@ -0,0 +1,103 @@
export interface TokenResponse {
access_token: string
refresh_token: string
id_token: string
expires_in: number
refresh_expires_in?: number
token_type: string
scope?: string
}
function realmBase(): string {
const cfg = useRuntimeConfig()
return `${cfg.keycloakUrl}/realms/${cfg.keycloakRealm}`
}
export function buildAuthUrl(opts: {
codeChallenge: string
state: string
redirectUri: string
loginHint?: string
}): string {
const cfg = useRuntimeConfig()
const params = new URLSearchParams({
client_id: cfg.keycloakClientId,
redirect_uri: opts.redirectUri,
response_type: 'code',
scope: 'openid profile email',
code_challenge: opts.codeChallenge,
code_challenge_method: 'S256',
state: opts.state,
})
if (opts.loginHint) params.set('login_hint', opts.loginHint)
return `${realmBase()}/protocol/openid-connect/auth?${params.toString()}`
}
export async function exchangeCodeForTokens(opts: {
code: string
codeVerifier: string
redirectUri: string
}): Promise<TokenResponse> {
const cfg = useRuntimeConfig()
const body = new URLSearchParams({
grant_type: 'authorization_code',
code: opts.code,
client_id: cfg.keycloakClientId,
client_secret: cfg.keycloakClientSecret,
redirect_uri: opts.redirectUri,
code_verifier: opts.codeVerifier,
})
return await $fetch<TokenResponse>(
`${realmBase()}/protocol/openid-connect/token`,
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
},
)
}
export async function refreshTokens(refreshToken: string): Promise<TokenResponse> {
const cfg = useRuntimeConfig()
const body = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: cfg.keycloakClientId,
client_secret: cfg.keycloakClientSecret,
})
return await $fetch<TokenResponse>(
`${realmBase()}/protocol/openid-connect/token`,
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
},
)
}
export function buildLogoutUrl(opts: {
idTokenHint: string
postLogoutRedirectUri: string
}): string {
const params = new URLSearchParams({
id_token_hint: opts.idTokenHint,
post_logout_redirect_uri: opts.postLogoutRedirectUri,
})
return `${realmBase()}/protocol/openid-connect/logout?${params.toString()}`
}
/**
* Decodifica payload de JWT sem validar assinatura uso server-only para
* extrair claims do id_token/access_token recém-emitido pelo Keycloak.
* NÃO USAR para validar tokens recebidos de terceiros.
*/
export function decodeJwtPayload(token: string): Record<string, unknown> {
const [, payload] = token.split('.')
if (!payload) return {}
try {
const json = Buffer.from(payload, 'base64url').toString('utf-8')
return JSON.parse(json) as Record<string, unknown>
} catch {
return {}
}
}

61
server/utils/pkce.ts Normal file
View File

@ -0,0 +1,61 @@
import pkceChallenge from 'pkce-challenge'
import { randomBytes } from 'node:crypto'
import type { H3Event } from 'h3'
export interface PkceArtifacts {
codeVerifier: string
codeChallenge: string
state: string
}
export async function generatePkce(): Promise<PkceArtifacts> {
const { code_verifier, code_challenge } = await pkceChallenge()
const state = randomBytes(16).toString('base64url')
return {
codeVerifier: code_verifier,
codeChallenge: code_challenge,
state,
}
}
// ─── PKCE state em Redis (single-use, TTL curto) ─────────────────────────────
export interface PkceStateData {
codeVerifier: string
returnTo: string
createdAt: number
}
export async function savePkceState(state: string, data: PkceStateData): Promise<void> {
const ttl = useRuntimeConfig().pkceTtlSeconds
await useRedis().set(`pkce:${state}`, JSON.stringify(data), 'EX', ttl)
}
/**
* e remove o state atomicamente single-use, previne replay.
*/
export async function consumePkceState(state: string): Promise<PkceStateData | null> {
if (!state) return null
const key = `pkce:${state}`
const pipe = useRedis().multi()
pipe.get(key)
pipe.del(key)
const results = await pipe.exec()
const raw = results?.[0]?.[1] as string | null | undefined
if (!raw) return null
try {
return JSON.parse(raw) as PkceStateData
} catch {
return null
}
}
/**
* Computa o redirect_uri do callback a partir do request coerente com o que foi enviado ao Keycloak.
*/
export function callbackUrlFromEvent(event: H3Event): string {
const host = getRequestHost(event, { xForwardedHost: true })
const proto = getRequestProtocol(event, { xForwardedProto: true })
return `${proto}://${host}/api/auth/callback`
}

View File

@ -0,0 +1,44 @@
export interface PrefeituraInfo {
codigoMunicipio: number
nomePrefeitura: string
dominio: string
template: string
pathLogo?: string
pathBackground?: string
}
interface ApiEnvelope<T> {
data: T
}
const CACHE_TTL_SECONDS = 300 // 5 min
export async function fetchPrefeituraInfo(dominio: string): Promise<PrefeituraInfo | null> {
if (!dominio) return null
const cacheKey = `prefeitura:${dominio}`
const cached = await useRedis().get(cacheKey)
if (cached) {
try {
return JSON.parse(cached) as PrefeituraInfo
} catch {
// cache corrompido — segue para refetch
}
}
const cfg = useRuntimeConfig()
try {
const res = await $fetch<ApiEnvelope<PrefeituraInfo>>(
`${cfg.coreApiUrl}/api/v1/publico/prefeitura/${encodeURIComponent(dominio)}`,
{ timeout: 8000 },
)
const info = res?.data
if (!info?.codigoMunicipio) return null
await useRedis().set(cacheKey, JSON.stringify(info), 'EX', CACHE_TTL_SECONDS)
return info
} catch (err) {
console.error(`[prefeitura] lookup falhou para '${dominio}':`, (err as Error).message)
return null
}
}

27
server/utils/redis.ts Normal file
View File

@ -0,0 +1,27 @@
import Redis from 'ioredis'
let _client: Redis | null = null
export function useRedis(): Redis {
if (_client) return _client
const url = useRuntimeConfig().redisUrl
if (!url) {
throw createError({
statusCode: 500,
statusMessage: 'REDIS_URL não configurada (definir NUXT_REDIS_URL).',
})
}
_client = new Redis(url, {
lazyConnect: false,
maxRetriesPerRequest: 3,
enableReadyCheck: true,
})
_client.on('error', (err) => {
console.error('[redis] error:', err.message)
})
return _client
}

126
server/utils/session.ts Normal file
View File

@ -0,0 +1,126 @@
import { randomBytes } from 'node:crypto'
import { decodeJwtPayload, refreshTokens } from './keycloak'
import type { H3Event } from 'h3'
const COOKIE_NAME = 'portal_sid'
const REFRESH_GUARD_MS = 30_000 // renova se restam <30s
export interface UserInfo {
sub: string
name?: string
preferred_username?: string
email?: string
realm_roles?: string[]
[key: string]: unknown
}
export interface SessionData {
accessToken: string
refreshToken: string
idToken: string
accessTokenExpiresAt: number
userInfo: UserInfo
createdAt: number
}
function genSid(): string {
return randomBytes(32).toString('base64url')
}
// ─── Operações de store ───────────────────────────────────────────────────────
export async function createSession(data: Omit<SessionData, 'createdAt'>): Promise<string> {
const sid = genSid()
const ttl = useRuntimeConfig().sessionTtlSeconds
const payload: SessionData = { ...data, createdAt: Date.now() }
await useRedis().set(`sess:${sid}`, JSON.stringify(payload), 'EX', ttl)
return sid
}
export async function readSession(sid: string | undefined): Promise<SessionData | null> {
if (!sid) return null
const raw = await useRedis().get(`sess:${sid}`)
if (!raw) return null
// sliding TTL — qualquer leitura estende a sessão
const ttl = useRuntimeConfig().sessionTtlSeconds
await useRedis().expire(`sess:${sid}`, ttl)
try {
return JSON.parse(raw) as SessionData
} catch {
return null
}
}
export async function updateSession(sid: string, patch: Partial<SessionData>): Promise<void> {
const current = await readSession(sid)
if (!current) return
const next: SessionData = { ...current, ...patch }
const ttl = useRuntimeConfig().sessionTtlSeconds
await useRedis().set(`sess:${sid}`, JSON.stringify(next), 'EX', ttl)
}
export async function deleteSession(sid: string | undefined): Promise<void> {
if (!sid) return
await useRedis().del(`sess:${sid}`)
}
// ─── Cookie helpers ───────────────────────────────────────────────────────────
export function setSessionCookie(event: H3Event, sid: string): void {
setCookie(event, COOKIE_NAME, sid, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: useRuntimeConfig().sessionTtlSeconds,
})
}
export function readSessionCookie(event: H3Event): string | undefined {
return getCookie(event, COOKIE_NAME)
}
export function clearSessionCookie(event: H3Event): void {
deleteCookie(event, COOKIE_NAME, { path: '/' })
}
// ─── Orquestração: token válido + refresh transparente ────────────────────────
export async function getValidAccessToken(sid: string): Promise<string | null> {
const session = await readSession(sid)
if (!session) return null
const now = Date.now()
if (session.accessTokenExpiresAt - now > REFRESH_GUARD_MS) {
return session.accessToken
}
try {
const tokens = await refreshTokens(session.refreshToken)
await updateSession(sid, {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
idToken: tokens.id_token ?? session.idToken,
accessTokenExpiresAt: Date.now() + tokens.expires_in * 1000,
})
return tokens.access_token
} catch {
await deleteSession(sid)
return null
}
}
// ─── Helper: monta UserInfo a partir do access_token ─────────────────────────
export function userInfoFromAccessToken(accessToken: string): UserInfo {
const claims = decodeJwtPayload(accessToken)
const realmAccess = claims.realm_access as { roles?: string[] } | undefined
return {
sub: String(claims.sub ?? ''),
name: claims.name as string | undefined,
preferred_username: claims.preferred_username as string | undefined,
email: claims.email as string | undefined,
realm_roles: realmAccess?.roles,
}
}

30
server/utils/tenant.ts Normal file
View File

@ -0,0 +1,30 @@
import type { H3Event } from 'h3'
const INVALID_SUBDOMAINS = new Set([
'0.0.0.0',
'www',
'api',
'admin',
'test',
'dev',
'development',
'staging',
'production',
'localhost',
])
const DEFAULT_TENANT = 'sistema'
export function tenantFromHost(host: string | undefined): string {
if (!host) return DEFAULT_TENANT
const sub = host.split(':')[0]?.split('.')[0]?.toLowerCase()
if (!sub || sub.length <= 1) return DEFAULT_TENANT
if (INVALID_SUBDOMAINS.has(sub)) return DEFAULT_TENANT
if (!/^[a-z0-9-]+$/.test(sub)) return DEFAULT_TENANT
return sub
}
export function tenantFromEvent(event: H3Event): string {
const host = getRequestHost(event, { xForwardedHost: true })
return tenantFromHost(host)
}

View File

@ -1,21 +0,0 @@
<script setup>
import { onMounted } from 'vue'
import { usePrefeituraStore } from '@/stores/prefeituraStore'
import { applyTemplate, applySurface } from '@/config/theme.config'
import AccessibilityWidget from '@/components/common/AccessibilityWidget.vue'
const prefeitura = usePrefeituraStore()
onMounted(() => {
applyTemplate(prefeitura.template ?? 'blue')
applySurface('slate')
})
</script>
<template>
<a href="#main-content" class="skip-link">Pular para o conteúdo principal</a>
<RouterView />
<Toast />
<AccessibilityWidget />
</template>

58
src/app.vue Normal file
View File

@ -0,0 +1,58 @@
<script setup>
import { ref, onMounted } from 'vue'
import { usePrefeituraStore } from '@/stores/prefeituraStore'
import { applyTemplate, applySurface } from '@/config/theme.config'
import logoModum from '@/assets/images/logo-modum-fiscal.png'
const prefeitura = usePrefeituraStore()
const themeReady = ref(false)
onMounted(() => {
applyTemplate(prefeitura.template ?? 'blue')
applySurface('slate')
requestAnimationFrame(() => {
themeReady.value = true
})
})
</script>
<template>
<!-- Splash de boot esconde o flash entre preset inicial e tema dinâmico da prefeitura -->
<Transition
enter-active-class="transition-opacity duration-150"
leave-active-class="transition-opacity duration-300"
enter-from-class="opacity-0"
leave-to-class="opacity-0"
>
<div
v-if="!themeReady"
class="fixed inset-0 z-[100] bg-slate-50 dark:bg-slate-950 flex items-center justify-center"
aria-hidden="true"
>
<div class="flex flex-col items-center gap-5">
<img
:src="prefeitura.pathLogo || logoModum"
alt=""
class="h-14 w-auto opacity-80 object-contain"
/>
<div class="w-7 h-7 border-2 border-slate-200 dark:border-slate-700 border-t-slate-500 dark:border-t-slate-400 rounded-full animate-spin" />
<p class="text-[11px] text-slate-400 dark:text-slate-500 uppercase tracking-[0.2em] font-medium">
Carregando
</p>
</div>
</div>
</Transition>
<a href="#main-content" class="skip-link">Pular para o conteúdo principal</a>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<Toast />
<LoginModal />
<ClientOnly>
<AccessibilityWidget />
</ClientOnly>
</template>

View File

@ -7,7 +7,7 @@ body {
font-size: 16px; /* base mínimo para leitura confortável */ font-size: 16px; /* base mínimo para leitura confortável */
} }
#app { #__nuxt {
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -1,37 +0,0 @@
import { prefeituraService } from '@/services/prefeituraService'
import { usePrefeituraStore } from '@/stores/prefeituraStore'
import { getTenant } from '@/utils/tenant'
const API_URL = import.meta.env.VITE_API_URL ?? ''
function resolverUrl(path) {
if (!path) return null
if (path.startsWith('http')) return path
return `${API_URL}${path}`
}
export async function bootstrapPrefeitura(pinia) {
const store = usePrefeituraStore(pinia)
const dominio = getTenant()
try {
const { data } = await prefeituraService.getPrefeituraInfo(dominio)
const info = data.data
store.$patch({
codigoMunicipio: info.codigoMunicipio,
nomePrefeitura: info.nomePrefeitura,
dominio: info.dominio,
template: info.template,
pathLogo: resolverUrl(info.pathLogo),
pathBackground: resolverUrl(info.pathBackground),
})
localStorage.setItem('current_municipio', info.codigoMunicipio)
localStorage.setItem('current_dominio', info.dominio)
return { success: true }
} catch {
return { success: false }
}
}

View File

@ -3,15 +3,16 @@ import { ref, computed, watch } from 'vue'
const props = defineProps({ const props = defineProps({
modelValue: { type: String, default: '' }, modelValue: { type: String, default: '' },
disabled: { type: Boolean, default: false },
}) })
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
const wrapper = ref(null)
const input = ref(props.modelValue) const input = ref(props.modelValue)
watch(() => props.modelValue, (v) => { input.value = v }) watch(() => props.modelValue, (v) => { input.value = v })
const apenasDigitos = computed(() => input.value.replace(/\D/g, '')) const apenasDigitos = computed(() => input.value.replace(/\D/g, ''))
const isCnpj = computed(() => apenasDigitos.value.length > 11) const isCnpj = computed(() => apenasDigitos.value.length > 11)
const valorFormatado = computed(() => { const valorFormatado = computed(() => {
@ -36,13 +37,20 @@ function onInput(e) {
input.value = raw input.value = raw
emit('update:modelValue', raw) emit('update:modelValue', raw)
} }
function focus() {
wrapper.value?.querySelector('input')?.focus()
}
defineExpose({ focus })
</script> </script>
<template> <template>
<div class="relative"> <div ref="wrapper" class="relative">
<InputText <InputText
:value="valorFormatado" :value="valorFormatado"
:placeholder="placeholder" :placeholder="placeholder"
:disabled="disabled"
inputmode="numeric" inputmode="numeric"
autocomplete="username" autocomplete="username"
class="w-full text-lg tracking-wide" class="w-full text-lg tracking-wide"
@ -52,8 +60,15 @@ function onInput(e) {
@input="onInput" @input="onInput"
/> />
<span <span
v-if="apenasDigitos.length > 0" v-if="disabled"
class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-400 font-medium" 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' }} {{ isCnpj ? 'CNPJ' : 'CPF' }}
</span> </span>

View File

@ -0,0 +1,110 @@
<script setup>
import { ref } from 'vue'
import { useLoginModal } from '@/composables/useLoginModal'
import { useAuth } from '@/composables/useAuth'
const { isOpen, returnTo, close } = useLoginModal()
const { login } = useAuth()
const documento = ref('')
const carregando = ref(false)
const erro = ref('')
async function entrar() {
const doc = documento.value.replace(/\D/g, '')
if (doc.length !== 11 && doc.length !== 14) {
erro.value = 'Informe um CPF (11 dígitos) ou CNPJ (14 dígitos) válido.'
return
}
erro.value = ''
carregando.value = true
try {
await login(doc, returnTo.value ?? '/portal/painel')
// login() faz window.location.href não retorna aqui
} catch (e) {
carregando.value = false
erro.value = e?.data?.statusMessage ?? 'Não foi possível iniciar o login.'
}
}
function onHide() {
documento.value = ''
erro.value = ''
carregando.value = false
close()
}
</script>
<template>
<Dialog
v-model:visible="isOpen"
modal
dismissable-mask
:draggable="false"
:closable="true"
:show-header="false"
:pt="{
root: { class: 'w-full max-w-sm mx-4 rounded-2xl overflow-hidden shadow-2xl' },
mask: { class: 'backdrop-blur-sm' },
}"
@hide="onHide"
>
<div class="bg-primary px-7 py-6 relative">
<button
class="absolute top-3 right-3 w-8 h-8 rounded-full flex items-center justify-center text-white/80 hover:text-white hover:bg-white/15 transition-colors"
aria-label="Fechar"
@click="onHide"
>
<i class="pi pi-times text-sm" aria-hidden="true" />
</button>
<div class="flex items-center gap-3 pr-10">
<div class="w-10 h-10 bg-white/20 rounded-xl flex items-center justify-center" aria-hidden="true">
<i class="pi pi-lock text-white text-lg" />
</div>
<div>
<h2 class="text-white font-bold text-base leading-tight">Faça login para continuar</h2>
<p class="text-white/80 text-xs mt-0.5">Acesso seguro ao Portal do Contribuinte</p>
</div>
</div>
</div>
<div class="px-7 py-6 space-y-5 bg-white dark:bg-slate-800">
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-200 mb-1.5">CPF ou CNPJ</label>
<DocumentoInput v-model="documento" @keyup.enter="entrar" />
<p v-if="erro" class="mt-1.5 text-xs text-red-600 dark:text-red-400 flex items-center gap-1">
<i class="pi pi-exclamation-circle text-xs" aria-hidden="true" />
{{ erro }}
</p>
</div>
<Button
label="Continuar"
icon="pi pi-arrow-right"
icon-pos="right"
class="w-full"
size="large"
:loading="carregando"
@click="entrar"
/>
<div class="text-center space-y-2 pt-1">
<NuxtLink
to="/primeiro-acesso"
class="block text-sm text-primary font-medium hover:underline"
@click="close"
>
Esqueci minha senha
</NuxtLink>
<NuxtLink
to="/credenciamento"
class="block text-xs text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 transition-colors"
@click="close"
>
Ainda não cadastrado? <span class="text-primary font-semibold">Credenciar-se</span>
</NuxtLink>
</div>
</div>
</Dialog>
</template>

View File

@ -2,9 +2,9 @@
import { ref, watch, onMounted } from 'vue' import { ref, watch, onMounted } from 'vue'
const aberto = ref(false) const aberto = ref(false)
const nivelFonte = ref(Number(localStorage.getItem('a11y-fonte') || 0)) const nivelFonte = ref(0)
const altoContraste = ref(localStorage.getItem('a11y-contraste') === '1') const altoContraste = ref(false)
const modoEscuro = ref(localStorage.getItem('a11y-escuro') === '1') const modoEscuro = ref(false)
const opcoesFonte = [ const opcoesFonte = [
{ nivel: 0, label: 'A', title: 'Texto normal' }, { nivel: 0, label: 'A', title: 'Texto normal' },
@ -18,23 +18,32 @@ function applyFonte(nivel) {
if (nivel === 2) document.documentElement.classList.add('a11y-font-xl') if (nivel === 2) document.documentElement.classList.add('a11y-font-xl')
} }
// Toda leitura de localStorage e DOM precisa estar dentro de onMounted
// caso contrário o componente quebra no SSR.
onMounted(() => { onMounted(() => {
nivelFonte.value = Number(localStorage.getItem('a11y-fonte') || 0)
altoContraste.value = localStorage.getItem('a11y-contraste') === '1'
modoEscuro.value = localStorage.getItem('a11y-escuro') === '1'
applyFonte(nivelFonte.value) applyFonte(nivelFonte.value)
document.documentElement.classList.toggle('a11y-contrast', altoContraste.value) document.documentElement.classList.toggle('a11y-contrast', altoContraste.value)
document.documentElement.classList.toggle('app-dark', modoEscuro.value) document.documentElement.classList.toggle('app-dark', modoEscuro.value)
}) })
watch(nivelFonte, (val) => { watch(nivelFonte, (val) => {
if (!import.meta.client) return
applyFonte(val) applyFonte(val)
localStorage.setItem('a11y-fonte', val) localStorage.setItem('a11y-fonte', val)
}) })
watch(altoContraste, (val) => { watch(altoContraste, (val) => {
if (!import.meta.client) return
document.documentElement.classList.toggle('a11y-contrast', val) document.documentElement.classList.toggle('a11y-contrast', val)
localStorage.setItem('a11y-contraste', val ? '1' : '0') localStorage.setItem('a11y-contraste', val ? '1' : '0')
}) })
watch(modoEscuro, (val) => { watch(modoEscuro, (val) => {
if (!import.meta.client) return
document.documentElement.classList.toggle('app-dark', val) document.documentElement.classList.toggle('app-dark', val)
localStorage.setItem('a11y-escuro', val ? '1' : '0') localStorage.setItem('a11y-escuro', val ? '1' : '0')
}) })

View File

@ -1,21 +1,32 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import { useAuthStore } from '@/stores/authStore'
import { usePrefeituraStore } from '@/stores/prefeituraStore' import { usePrefeituraStore } from '@/stores/prefeituraStore'
import { useAuth } from '@/composables/useAuth'
import { useFocusLoginInput } from '@/composables/useFocusLoginInput'
import logoFallback from '@/assets/images/logo-modum-fiscal.png' import logoFallback from '@/assets/images/logo-modum-fiscal.png'
const auth = useAuthStore() const { isAuthenticated } = useAuth()
const prefeitura = usePrefeituraStore() const prefeitura = usePrefeituraStore()
const route = useRoute()
const router = useRouter()
const { request: requestFocusLogin } = useFocusLoginInput()
const logoSrc = computed(() => prefeitura.pathLogo || logoFallback) const logoSrc = computed(() => prefeitura.pathLogo || logoFallback)
async function clicarEntrar() {
if (route.path !== '/') {
await router.push('/')
}
requestFocusLogin()
}
</script> </script>
<template> <template>
<header class="bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700"> <header class="bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
<RouterLink <NuxtLink
:to="{ name: 'home' }" to="/"
class="flex items-center gap-3 min-w-0" class="flex items-center gap-3 min-w-0"
:aria-label="`Ir para a página inicial — ${prefeitura.nomePrefeitura || 'ModumFiscal'}`" :aria-label="`Ir para a página inicial — ${prefeitura.nomePrefeitura || 'ModumFiscal'}`"
> >
@ -31,28 +42,32 @@ const logoSrc = computed(() => prefeitura.pathLogo || logoFallback)
{{ prefeitura.nomePrefeitura || 'ModumFiscal' }} {{ prefeitura.nomePrefeitura || 'ModumFiscal' }}
</p> </p>
</div> </div>
</RouterLink> </NuxtLink>
<nav aria-label="Navegação principal" class="hidden md:flex items-center gap-4"> <nav aria-label="Navegação principal" class="hidden md:flex items-center gap-4">
<RouterLink <NuxtLink
:to="{ name: 'servicos' }" to="/servicos"
class="text-sm text-slate-600 dark:text-slate-300 hover:text-primary transition-colors" class="text-sm text-slate-600 dark:text-slate-300 hover:text-primary transition-colors"
:aria-current="$route.name === 'servicos' ? 'page' : undefined" :aria-current="route.path === '/servicos' ? 'page' : undefined"
> >
Serviços Serviços
</RouterLink> </NuxtLink>
</nav> </nav>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<template v-if="auth.isAuthenticated"> <template v-if="isAuthenticated">
<RouterLink :to="{ name: 'painel' }"> <NuxtLink to="/portal/painel">
<Button label="Meu Painel" icon="pi pi-user" size="small" aria-label="Ir para o meu painel" /> <Button label="Meu Painel" icon="pi pi-user" size="small" aria-label="Ir para o meu painel" />
</RouterLink> </NuxtLink>
</template> </template>
<template v-else> <template v-else>
<RouterLink :to="{ name: 'home' }"> <Button
<Button label="Entrar" icon="pi pi-sign-in" size="small" aria-label="Entrar no portal" /> label="Entrar"
</RouterLink> icon="pi pi-sign-in"
size="small"
aria-label="Entrar no portal"
@click="clicarEntrar"
/>
</template> </template>
</div> </div>

View File

@ -6,14 +6,12 @@ defineProps({
to: { type: [String, Object], default: null }, to: { type: [String, Object], default: null },
requiresAuth: { type: Boolean, default: false }, requiresAuth: { type: Boolean, default: false },
}) })
const cardClasses = 'group flex flex-col gap-3 bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5 hover:border-primary/40 dark:hover:border-primary/50 hover:shadow-md transition-all duration-200 cursor-pointer'
</script> </script>
<template> <template>
<component <NuxtLink v-if="to" :to="to" :class="cardClasses">
:is="to ? 'RouterLink' : 'div'"
:to="to ?? undefined"
class="group flex flex-col gap-3 bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5 hover:border-primary/40 dark:hover:border-primary/50 hover:shadow-md transition-all duration-200 cursor-pointer"
>
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div class="w-10 h-10 bg-primary/8 dark:bg-primary/15 rounded-lg flex items-center justify-center group-hover:bg-primary/15 dark:group-hover:bg-primary/25 transition-colors"> <div class="w-10 h-10 bg-primary/8 dark:bg-primary/15 rounded-lg flex items-center justify-center group-hover:bg-primary/15 dark:group-hover:bg-primary/25 transition-colors">
<i :class="['pi', icon, 'text-primary text-lg']" /> <i :class="['pi', icon, 'text-primary text-lg']" />
@ -28,5 +26,18 @@ defineProps({
<span>Acessar</span> <span>Acessar</span>
<i class="pi pi-arrow-right text-xs" /> <i class="pi pi-arrow-right text-xs" />
</div> </div>
</component> </NuxtLink>
<div v-else :class="cardClasses">
<div class="flex items-start justify-between">
<div class="w-10 h-10 bg-primary/8 dark:bg-primary/15 rounded-lg flex items-center justify-center group-hover:bg-primary/15 dark:group-hover:bg-primary/25 transition-colors">
<i :class="['pi', icon, 'text-primary text-lg']" />
</div>
<i v-if="requiresAuth" class="pi pi-lock text-slate-300 dark:text-slate-600 text-xs" title="Requer login" />
</div>
<div>
<p class="font-semibold text-slate-800 dark:text-slate-100 text-sm">{{ titulo }}</p>
<p v-if="descricao" class="text-xs text-slate-500 dark:text-slate-400 mt-1 leading-relaxed">{{ descricao }}</p>
</div>
</div>
</template> </template>

43
src/composables/useApi.ts Normal file
View File

@ -0,0 +1,43 @@
import type { FetchOptions } from 'ofetch'
/**
* Wrapper para chamadas autenticadas ao core-api via BFF proxy.
*
* - Todas as requests passam por `/api/proxy/**` (que injeta Bearer + tenant headers no server)
* - Headers CSRF (`X-Requested-With: fetch`) sempre injetados exigido pelo middleware do BFF em mutating methods
*
* Uso típico:
* const api = useApi()
* const debitos = await api.get<DebitoDTO[]>('portal/contribuinte/debitos')
* const novo = await api.post<DebitoDTO>('portal/contribuinte/debitos', payload)
*/
export function useApi() {
function buildUrl(path: string): string {
const clean = path.startsWith('/') ? path : `/${path}`
return `/api/proxy${clean}`
}
async function request<T>(path: string, options: FetchOptions = {}): Promise<T> {
return await $fetch<T>(buildUrl(path), {
...options,
headers: {
'X-Requested-With': 'fetch',
...(options.headers ?? {}),
},
})
}
return {
request,
get: <T>(path: string, opts?: FetchOptions) =>
request<T>(path, { ...opts, method: 'GET' }),
post: <T>(path: string, body?: unknown, opts?: FetchOptions) =>
request<T>(path, { ...opts, method: 'POST', body }),
put: <T>(path: string, body?: unknown, opts?: FetchOptions) =>
request<T>(path, { ...opts, method: 'PUT', body }),
patch: <T>(path: string, body?: unknown, opts?: FetchOptions) =>
request<T>(path, { ...opts, method: 'PATCH', body }),
delete: <T>(path: string, opts?: FetchOptions) =>
request<T>(path, { ...opts, method: 'DELETE' }),
}
}

View File

@ -0,0 +1,65 @@
import { computed } from 'vue'
import { useAuthStore } from '@/stores/authStore'
interface MeResponse {
name: string
documento: string
email: string
roles: string[]
}
const FETCH_HEADERS = { 'X-Requested-With': 'fetch' }
export function useAuth() {
const store = useAuthStore()
const router = useRouter()
async function login(documento?: string, returnTo?: string) {
const res = await $fetch<{ authUrl: string }>('/api/auth/login', {
method: 'POST',
headers: FETCH_HEADERS,
body: { documento, returnTo },
})
if (import.meta.client) {
window.location.href = res.authUrl
}
}
async function fetchMe(): Promise<MeResponse | null> {
try {
const me = await $fetch<MeResponse>('/api/auth/me')
store.setUser(me)
return me
} catch {
store.clearUser()
return null
}
}
async function logout() {
try {
const res = await $fetch<{ logoutUrl: string }>('/api/auth/logout', {
method: 'POST',
headers: FETCH_HEADERS,
})
store.clearUser()
if (import.meta.client) {
window.location.href = res.logoutUrl
}
} catch {
store.clearUser()
await router.push('/')
}
}
return {
user: computed(() => store.user),
isAuthenticated: computed(() => store.isAuthenticated),
nomeUsuario: computed(() => store.nomeUsuario),
documento: computed(() => store.documento),
roles: computed(() => store.roles),
login,
logout,
fetchMe,
}
}

View File

@ -0,0 +1,20 @@
/**
* Sinalização cross-página: quando o usuário clica em "Entrar" no AppHeader
* mas a home ainda não está montada (estava em outra rota), o request fica
* armazenado no state. Ao montar, a home consome e focus no documento.
*
* Se a home está montada, o watch reage imediatamente.
*/
export function useFocusLoginInput() {
const requested = useState<boolean>('focusLoginInput', () => false)
function request() {
requested.value = true
}
function consume() {
requested.value = false
}
return { requested, request, consume }
}

View File

@ -0,0 +1,26 @@
/**
* Estado global do modal de login.
*
* Quando o middleware `auth` bloqueia uma rota, ele chama `open(toPath)`
* o modal abre e, ao submeter, redireciona o usuário pra `toPath` após o
* fluxo Keycloak completar.
*
* `useState` garante SSR-safety: state separado por request no server,
* singleton no client.
*/
export function useLoginModal() {
const isOpen = useState<boolean>('loginModal:isOpen', () => false)
const returnTo = useState<string | null>('loginModal:returnTo', () => null)
function open(path?: string | null) {
returnTo.value = path ?? null
isOpen.value = true
}
function close() {
isOpen.value = false
returnTo.value = null
}
return { isOpen, returnTo, open, close }
}

View File

@ -1,41 +0,0 @@
import axios from 'axios'
import { useAuthStore } from '@/stores/authStore'
import { usePrefeituraStore } from '@/stores/prefeituraStore'
const BASE_URL = `${import.meta.env.VITE_API_URL ?? ''}/api/v1`
function addTenantHeaders(config) {
const prefeitura = usePrefeituraStore()
if (prefeitura.codigoMunicipio) config.headers['X-Municipio'] = prefeitura.codigoMunicipio
if (prefeitura.dominio) config.headers['X-Dominio'] = prefeitura.dominio
return config
}
// ─── Cliente público (sem autenticação) ───────────────────────────────────────
// Usado no bootstrap de prefeitura e em serviços públicos (certidão, IPTU)
export const apiClientPublico = axios.create({ baseURL: BASE_URL, timeout: 10000 })
apiClientPublico.interceptors.request.use((config) => addTenantHeaders(config))
// ─── Cliente autenticado ───────────────────────────────────────────────────────
// Usado nas rotas /portal/* que exigem login
const apiClient = axios.create({ baseURL: BASE_URL, timeout: 10000 })
apiClient.interceptors.request.use((config) => {
const auth = useAuthStore()
if (auth.token) config.headers.Authorization = `Bearer ${auth.token}`
return addTenantHeaders(config)
})
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
useAuthStore().clearSession()
window.location.href = '/'
}
return Promise.reject(error)
},
)
export default apiClient

View File

@ -7,11 +7,7 @@
<!-- tabindex="-1" permite que o skip link mova o foco para --> <!-- tabindex="-1" permite que o skip link mova o foco para -->
<main id="main-content" tabindex="-1" class="flex-1 outline-none"> <main id="main-content" tabindex="-1" class="flex-1 outline-none">
<RouterView v-slot="{ Component }"> <slot />
<Transition name="page" mode="out-in">
<component :is="Component" />
</Transition>
</RouterView>
</main> </main>
<AppFooter /> <AppFooter />

View File

@ -1,21 +1,20 @@
<script setup> <script setup>
import { useAuthStore } from '@/stores/authStore' import { useAuth } from '@/composables/useAuth'
import { authService } from '@/services/authService'
const auth = useAuthStore() const { nomeUsuario, logout } = useAuth()
const route = useRoute()
const navItems = [ const navItems = [
{ name: 'painel', label: 'Painel' }, { path: '/portal/painel', label: 'Painel' },
{ name: 'debitos', label: 'Débitos' }, { path: '/portal/debitos', label: 'Débitos' },
{ name: 'certidoes-portal', label: 'Certidões' }, { path: '/portal/certidoes', label: 'Certidões' },
{ name: 'alvaras', label: 'Alvarás' }, { path: '/portal/alvaras', label: 'Alvarás' },
{ name: 'pagamentos', label: 'Pagamentos' }, { path: '/portal/pagamentos', label: 'Pagamentos' },
{ name: 'dados', label: 'Dados Cadastrais' }, { path: '/portal/dados', label: 'Dados Cadastrais' },
] ]
function sair() { function sair() {
auth.clearSession() logout()
authService.logout()
} }
</script> </script>
@ -23,8 +22,8 @@ function sair() {
<div class="min-h-screen flex flex-col bg-slate-50 dark:bg-slate-950"> <div class="min-h-screen flex flex-col bg-slate-50 dark:bg-slate-950">
<header class="bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700 sticky top-0 z-40" role="banner"> <header class="bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700 sticky top-0 z-40" role="banner">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
<RouterLink <NuxtLink
:to="{ name: 'painel' }" to="/portal/painel"
class="flex items-center gap-3" class="flex items-center gap-3"
aria-label="Ir para o painel principal" aria-label="Ir para o painel principal"
> >
@ -32,24 +31,24 @@ function sair() {
<i class="pi pi-building text-white text-sm" aria-hidden="true" /> <i class="pi pi-building text-white text-sm" aria-hidden="true" />
</div> </div>
<span class="font-semibold text-slate-800 dark:text-slate-100">Portal do Contribuinte</span> <span class="font-semibold text-slate-800 dark:text-slate-100">Portal do Contribuinte</span>
</RouterLink> </NuxtLink>
<nav aria-label="Menu do contribuinte" class="hidden md:flex items-center gap-1"> <nav aria-label="Menu do contribuinte" class="hidden md:flex items-center gap-1">
<RouterLink <NuxtLink
v-for="item in navItems" v-for="item in navItems"
:key="item.name" :key="item.path"
:to="{ name: item.name }" :to="item.path"
class="px-4 py-2 rounded-lg text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 hover:text-slate-900 dark:hover:text-slate-100 transition-colors" class="px-4 py-2 rounded-lg text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 hover:text-slate-900 dark:hover:text-slate-100 transition-colors"
active-class="bg-primary/10 text-primary font-semibold" active-class="bg-primary/10 text-primary font-semibold"
:aria-current="$route.name === item.name ? 'page' : undefined" :aria-current="route.path === item.path ? 'page' : undefined"
> >
{{ item.label }} {{ item.label }}
</RouterLink> </NuxtLink>
</nav> </nav>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="hidden sm:block text-sm text-slate-600 dark:text-slate-300" aria-live="polite"> <span class="hidden sm:block text-sm text-slate-600 dark:text-slate-300" aria-live="polite">
{{ auth.nomeUsuario }} {{ nomeUsuario }}
</span> </span>
<Button <Button
label="Sair" label="Sair"
@ -65,11 +64,7 @@ function sair() {
</header> </header>
<main id="main-content" tabindex="-1" class="flex-1 max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8 py-8 outline-none"> <main id="main-content" tabindex="-1" class="flex-1 max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8 py-8 outline-none">
<RouterView v-slot="{ Component }"> <slot />
<Transition name="page" mode="out-in">
<component :is="Component" />
</Transition>
</RouterView>
</main> </main>
<AppFooter /> <AppFooter />

View File

@ -1,39 +0,0 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import PrimeVue from 'primevue/config'
import ToastService from 'primevue/toastservice'
import ConfirmationService from 'primevue/confirmationservice'
import App from './App.vue'
import router from './router'
import { primeVueConfig } from './config/primevue.config'
import { bootstrapPrefeitura } from './bootstrap/prefeituraBoot'
import '@/assets/main.css'
import '@/assets/layout/layout.scss'
async function startApp() {
const app = createApp(App)
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
app.use(pinia)
if (import.meta.env.VITE_USE_MOCK === 'true') {
const { setupMocks } = await import('@/mocks/mockInterceptor')
setupMocks(pinia)
} else {
await bootstrapPrefeitura(pinia)
}
app.use(router)
app.use(PrimeVue, primeVueConfig)
app.use(ToastService)
app.use(ConfirmationService)
app.mount('#app')
}
startApp()

20
src/middleware/auth.ts Normal file
View File

@ -0,0 +1,20 @@
import { useAuthStore } from '@/stores/authStore'
import { useLoginModal } from '@/composables/useLoginModal'
/**
* Guarda rotas autenticadas (/portal/**).
*
* Quando bloqueia: abre o modal global de login (com a rota pretendida) e
* leva o usuário pra home, onde o modal fica visível.
*/
export default defineNuxtRouteMiddleware((to) => {
const auth = useAuthStore()
if (auth.isAuthenticated) return
if (import.meta.client) {
const { open } = useLoginModal()
open(to.fullPath)
}
return navigateTo('/')
})

View File

@ -1,281 +0,0 @@
/**
* Mock interceptor para desenvolvimento sem backend.
* Ativado via VITE_USE_MOCK=true em .env.development.local
*
* Técnica: injeta config.adapter em cada request que bate em uma rota mockada.
* O adapter retorna dados falsos diretamente, sem fazer chamada HTTP.
* Simula latência aleatória de 400800ms para comportamento realista.
*/
import apiClient, { apiClientPublico } from '@/config/apiClient'
import { useAuthStore } from '@/stores/authStore'
// ─── Helpers ─────────────────────────────────────────────────────────────────
function envelope(data) {
return {
timestamp: new Date().toISOString(),
statusCode: 200,
responseType: 'SUCCESS',
message: 'OK',
data,
}
}
// PDF mínimo válido — suficiente para o browser abrir/baixar
const FAKE_PDF = '%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj\nxref\n0 4\n0000000000 65535 f\n0000000009 00000 n\n0000000068 00000 n\n0000000125 00000 n\ntrailer<</Size 4/Root 1 0 R>>\nstartxref\n240\n%%EOF'
const fakePdf = () => new Blob([FAKE_PDF], { type: 'application/pdf' })
// ─── Rotas mockadas ───────────────────────────────────────────────────────────
const routes = [
// ── Certidão pública ──────────────────────────────────────────────────
{
test: (url, m) => /\/publico\/certidao\/consultar/.test(url) && m === 'get',
data: envelope({ situacao: 'NEGATIVA', nomeContribuinte: 'João da Silva Santos' }),
},
{
test: (url, m) => /\/publico\/certidao\/emitir/.test(url) && m === 'get',
blob: true,
},
// ── IPTU público ──────────────────────────────────────────────────────
{
test: (url, m) => /\/publico\/iptu\/consultar/.test(url) && m === 'get',
data: envelope({
content: [
{
inscricaoImobiliaria: '0001.001.0001.001',
enderecoCompleto: 'Rua das Flores, 100 — Centro',
debitos: [
{ id: 'd1', descricao: 'IPTU 2025 — Cota 4/10', vencimento: '30/04/2025', valor: 125.90, valorAtualizado: 138.49, status: 'VENCIDO' },
{ id: 'd2', descricao: 'IPTU 2025 — Cota 5/10', vencimento: '31/05/2025', valor: 125.90, valorAtualizado: 125.90, status: 'A_VENCER' },
],
},
{
inscricaoImobiliaria: '0001.001.0002.001',
enderecoCompleto: 'Av. Principal, 250 — Bairro Novo',
debitos: [],
},
],
}),
},
{ test: (url, m) => /\/publico\/iptu\/carne/.test(url) && m === 'get', blob: true },
{ test: (url, m) => /\/publico\/iptu\/boleto/.test(url) && m === 'get', blob: true },
// ── Primeiro Acesso ───────────────────────────────────────────────────
{
test: (url, m) => /\/publico\/primeiro-acesso\/verificar/.test(url) && m === 'get',
data: envelope({
nome: 'Maria Aparecida Santos',
canais: [
{ tipo: 'EMAIL', valor: 'ma***@gmail.com' },
{ tipo: 'WHATSAPP', valor: '(98) *****-8901' },
],
}),
},
{
test: (url, m) => /\/publico\/primeiro-acesso\/codigo/.test(url) && m === 'post',
data: envelope({ enviado: true }),
},
{
test: (url, m) => /\/publico\/primeiro-acesso\/validar/.test(url) && m === 'post',
data: envelope({ token: 'mock-reset-token-abc123' }),
},
{
test: (url, m) => /\/publico\/primeiro-acesso\/senha/.test(url) && m === 'post',
data: envelope({ sucesso: true }),
},
// ── Credenciamento ────────────────────────────────────────────────────
{
test: (url, m) => /\/publico\/credenciamento\/verificar/.test(url) && m === 'get',
data: envelope({ situacao: 'APTO' }),
},
{
test: (url, m) => /\/publico\/cep\//.test(url) && m === 'get',
data: envelope({ logradouro: 'Rua das Acácias', bairro: 'Centro', localidade: 'Tutóia', uf: 'MA' }),
},
{
test: (url, m) => /\/publico\/credenciamento\/solicitar/.test(url) && m === 'post',
data: envelope({ protocolo: 'CRED-2025-00123' }),
},
// ── Portal — Painel ───────────────────────────────────────────────────
{
test: (url, m) => /\/contribuinte\/painel\/resumo/.test(url) && m === 'get',
data: envelope({
totalDebitos: 1250.90,
certidoesAtivas: 2,
alvarasAndamento: 2,
ultimoPagamento: 430.00,
debitosVencidos: 1,
}),
},
{
test: (url, m) => /\/contribuinte\/painel\/atividades/.test(url) && m === 'get',
data: envelope({
content: [
{ tipo: 'PAGAMENTO', descricao: 'IPTU 2025 — Parcela 3/10 paga', data: '15/05/2025' },
{ tipo: 'CERTIDAO', descricao: 'Certidão Negativa emitida', data: '10/05/2025' },
{ tipo: 'DEBITO', descricao: 'ISS 1º Trimestre 2025 lançado', data: '01/04/2025' },
{ tipo: 'ALVARA', descricao: 'Alvará de Funcionamento — documento solicitado', data: '28/03/2025' },
],
}),
},
// ── Portal — Débitos ──────────────────────────────────────────────────
{
test: (url, m) => /\/contribuinte\/debitos$/.test(url) && m === 'get',
data: envelope({
content: [
{ id: 'deb1', descricao: 'IPTU 2025 — Cota 4/10', tipo: 'IPTU', referencia: 'ABR/2025', vencimento: '30/04/2025', valor: 125.90, valorAtualizado: 138.49, status: 'VENCIDO' },
{ id: 'deb2', descricao: 'IPTU 2025 — Cota 5/10', tipo: 'IPTU', referencia: 'MAI/2025', vencimento: '31/05/2025', valor: 125.90, valorAtualizado: 125.90, status: 'A_VENCER' },
{ id: 'deb3', descricao: 'ISS — 1º Trimestre 2025', tipo: 'ISS', referencia: '1T/2025', vencimento: '28/02/2025', valor: 520.00, valorAtualizado: 572.00, status: 'VENCIDO' },
{ id: 'deb4', descricao: 'Taxa de Licença 2025', tipo: 'TAXA', referencia: '2025', vencimento: '30/06/2025', valor: 180.00, valorAtualizado: 180.00, status: 'A_VENCER' },
{ id: 'deb5', descricao: 'IPTU 2024 — Parcelamento', tipo: 'IPTU', referencia: '2024', vencimento: '15/06/2025', valor: 307.10, valorAtualizado: 307.10, status: 'PARCELADO' },
],
}),
},
{ test: (url, m) => /\/contribuinte\/debitos\/.+\/guia/.test(url) && m === 'get', blob: true },
// ── Portal — Certidões ────────────────────────────────────────────────
{
test: (url, m) => /\/contribuinte\/certidoes$/.test(url) && m === 'get',
data: envelope({
content: [
{ id: 'cert1', tipo: 'Certidão Negativa de Débitos', numero: 'CN-2025-00481', dataEmissao: '10/05/2025', dataValidade: '07/11/2025', status: 'ATIVA' },
{ id: 'cert2', tipo: 'Positiva com Efeitos de Negativa', numero: 'CPN-2025-00219', dataEmissao: '03/03/2025', dataValidade: '29/08/2025', status: 'ATIVA' },
{ id: 'cert3', tipo: 'Certidão Negativa de Débitos', numero: 'CN-2024-01102', dataEmissao: '15/10/2024', dataValidade: '13/04/2025', status: 'VENCIDA' },
],
}),
},
{ test: (url, m) => /\/contribuinte\/certidoes\/.+\/pdf/.test(url) && m === 'get', blob: true },
// ── Portal — Alvarás ──────────────────────────────────────────────────
{
test: (url, m) => /\/contribuinte\/alvaras/.test(url) && m === 'get',
data: envelope({
content: [
{
id: 'alv1', tipo: 'Alvará de Funcionamento — Comércio Varejista',
numeroProcesso: 'ALV-2025-00342', status: 'EM_ANALISE', ultimaAtualizacao: '14/05/2025',
etapas: [
{ nome: 'Protocolo', concluida: true, atual: false },
{ nome: 'Análise', concluida: false, atual: true },
{ nome: 'Vistoria', concluida: false, atual: false },
{ nome: 'Emissão', concluida: false, atual: false },
],
},
{
id: 'alv2', tipo: 'Alvará de Construção — Reforma Residencial',
numeroProcesso: 'ALV-2024-00891', status: 'AGUARDANDO_DOCUMENTOS', ultimaAtualizacao: '02/04/2025',
etapas: [
{ nome: 'Protocolo', concluida: true, atual: false },
{ nome: 'Docs', concluida: false, atual: true },
{ nome: 'Análise', concluida: false, atual: false },
{ nome: 'Emissão', concluida: false, atual: false },
],
},
{
id: 'alv3', tipo: 'Alvará de Funcionamento — Bares e Restaurantes',
numeroProcesso: 'ALV-2023-00114', status: 'DEFERIDO', ultimaAtualizacao: '10/01/2024',
etapas: [
{ nome: 'Protocolo', concluida: true, atual: false },
{ nome: 'Análise', concluida: true, atual: false },
{ nome: 'Vistoria', concluida: true, atual: false },
{ nome: 'Emissão', concluida: true, atual: false },
],
},
],
}),
},
// ── Portal — Pagamentos ───────────────────────────────────────────────
{
test: (url, m) => /\/contribuinte\/pagamentos/.test(url) && m === 'get',
data: envelope({
content: [
{ id: 'pag1', descricao: 'IPTU 2025 — Cota 3/10', referencia: 'MAR/2025', dataPagamento: '28/03/2025', formaPagamento: 'PIX', valor: 125.90 },
{ id: 'pag2', descricao: 'IPTU 2025 — Cota 2/10', referencia: 'FEV/2025', dataPagamento: '27/02/2025', formaPagamento: 'BOLETO', valor: 125.90 },
{ id: 'pag3', descricao: 'ISS — 4º Trimestre 2024', referencia: '4T/2024', dataPagamento: '15/01/2025', formaPagamento: 'BOLETO', valor: 480.00 },
{ id: 'pag4', descricao: 'Taxa Coleta Lixo 2024', referencia: '2024', dataPagamento: '10/12/2024', formaPagamento: 'CARTAO', valor: 98.50 },
{ id: 'pag5', descricao: 'IPTU 2025 — Cota 1/10', referencia: 'JAN/2025', dataPagamento: '30/01/2025', formaPagamento: 'TRANSFERENCIA', valor: 125.90 },
],
}),
},
{ test: (url, m) => /\/contribuinte\/pagamentos\/.+\/comprovante/.test(url) && m === 'get', blob: true },
// ── Portal — Dados Cadastrais ─────────────────────────────────────────
{
test: (url, m) => /\/contribuinte\/dados$/.test(url) && m === 'get',
data: envelope({
tipoPessoa: 'FISICA',
documento: '123.456.789-00',
nomeCompleto: 'João da Silva Santos',
dataNascimento: '15/06/1975',
email: 'joao.santos@email.com',
telefone: '(98) 99123-4567',
whatsapp: true,
endereco: {
cep: '65900-000',
logradouro: 'Rua das Flores',
numero: '100',
complemento: 'Apto 201',
bairro: 'Centro',
cidade: 'Tutóia',
uf: 'MA',
},
}),
},
{
test: (url, m) => /\/contribuinte\/dados\/contato/.test(url) && m === 'put',
data: envelope({ atualizado: true }),
},
]
// ─── Engine ───────────────────────────────────────────────────────────────────
function buildAdapter(route) {
return (config) => {
const ms = 400 + Math.random() * 400
return new Promise((resolve) =>
setTimeout(() => resolve({
data: route.blob ? fakePdf() : route.data,
status: 200,
statusText: 'OK',
headers: { 'content-type': route.blob ? 'application/pdf' : 'application/json' },
config,
request: {},
}), ms),
)
}
}
function applyInterceptor(client) {
client.interceptors.request.use((config) => {
const url = config.url ?? ''
const method = (config.method ?? 'get').toLowerCase()
const route = routes.find((r) => r.test(url, method))
if (route) config.adapter = buildAdapter(route)
return config
})
}
// ─── Entry point ─────────────────────────────────────────────────────────────
export function setupMocks(pinia) {
// Sessão fake para acessar rotas autenticadas do /portal/*
const auth = useAuthStore(pinia)
auth.setSession('mock-dev-token', {
name: 'João da Silva',
preferred_username: '12345678900',
email: 'joao@mock.dev',
})
applyInterceptor(apiClientPublico)
applyInterceptor(apiClient)
console.info('[mock] Interceptor ativo — todas as chamadas à API estão sendo mockadas.')
}

View File

@ -1,31 +1,20 @@
<script setup> <script setup>
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { credenciamentoService } from '@/services/credenciamentoService' import { credenciamentoService } from '@/services/credenciamentoService'
import DocumentoInput from '@/components/auth/DocumentoInput.vue'
const router = useRouter() const router = useRouter()
// Wizard state
const etapa = ref(0) const etapa = ref(0)
// 0 Documento | 1 Dados Pessoais | 2 Endereço | 3 Contato | 4 Representante | 5 Revisão | 6 Sucesso
const carregando = ref(false) const carregando = ref(false)
const erro = ref('') const erro = ref('')
// Dados por etapa
const form = ref({ const form = ref({
// Etapa 0
documento: '', documento: '',
tipoPessoa: '', // FISICA | JURIDICA tipoPessoa: '',
// Etapa 1 dados pessoais/empresariais
nomeCompleto: '', nomeCompleto: '',
nomeFantasia: '', // só PJ nomeFantasia: '',
dataNascimento: '', dataNascimento: '',
inscricaoEstadual: '', // só PJ inscricaoEstadual: '',
// Etapa 2 endereço
cep: '', cep: '',
logradouro: '', logradouro: '',
numero: '', numero: '',
@ -33,20 +22,15 @@ const form = ref({
bairro: '', bairro: '',
cidade: '', cidade: '',
uf: '', uf: '',
// Etapa 3 contato
email: '', email: '',
emailConfirm: '', emailConfirm: '',
telefone: '', telefone: '',
whatsapp: false, whatsapp: false,
// Etapa 4 representante legal (só PJ)
representanteNome: '', representanteNome: '',
representanteCpf: '', representanteCpf: '',
representanteCargo: '', representanteCargo: '',
}) })
// Computeds
const docDigitos = computed(() => form.value.documento.replace(/\D/g, '')) const docDigitos = computed(() => form.value.documento.replace(/\D/g, ''))
const isPJ = computed(() => form.value.tipoPessoa === 'JURIDICA') const isPJ = computed(() => form.value.tipoPessoa === 'JURIDICA')
@ -62,36 +46,34 @@ const etapaLabels = computed(() => {
const emailsIguais = computed(() => form.value.email === form.value.emailConfirm) const emailsIguais = computed(() => form.value.email === form.value.emailConfirm)
const emailValido = computed(() => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.value.email)) const emailValido = computed(() => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.value.email))
// Etapa 0 verificar documento
async function verificarDocumento() { async function verificarDocumento() {
const d = docDigitos.value const d = docDigitos.value
if (d.length !== 11 && d.length !== 14) return if (d.length !== 11 && d.length !== 14) return
carregando.value = true carregando.value = true
erro.value = '' erro.value = ''
try { try {
const { data } = await credenciamentoService.verificarDocumento(d) const res = await credenciamentoService.verificarDocumento(d)
if (data.data.situacao === 'JA_CREDENCIADO') { if (res.data.situacao === 'JA_CREDENCIADO') {
erro.value = 'Este documento já possui cadastro. Use "Esqueci minha senha" se precisar recuperar o acesso.' erro.value = 'Este documento já possui cadastro. Use "Esqueci minha senha" se precisar recuperar o acesso.'
return return
} }
form.value.tipoPessoa = d.length === 14 ? 'JURIDICA' : 'FISICA' form.value.tipoPessoa = d.length === 14 ? 'JURIDICA' : 'FISICA'
etapa.value = 1 etapa.value = 1
} catch (e) { } catch (e) {
erro.value = e.response?.data?.description ?? 'Não foi possível verificar o documento. Tente novamente.' erro.value = e?.data?.description ?? 'Não foi possível verificar o documento. Tente novamente.'
} finally { } finally {
carregando.value = false carregando.value = false
} }
} }
// Etapa 2 busca de CEP
const buscandoCep = ref(false) const buscandoCep = ref(false)
async function buscarCep() { async function buscarCep() {
const cep = form.value.cep.replace(/\D/g, '') const cep = form.value.cep.replace(/\D/g, '')
if (cep.length !== 8) return if (cep.length !== 8) return
buscandoCep.value = true buscandoCep.value = true
try { try {
const { data } = await credenciamentoService.buscarCep(cep) const res = await credenciamentoService.buscarCep(cep)
const end = data.data const end = res.data
form.value.logradouro = end.logradouro ?? '' form.value.logradouro = end.logradouro ?? ''
form.value.bairro = end.bairro ?? '' form.value.bairro = end.bairro ?? ''
form.value.cidade = end.localidade ?? '' form.value.cidade = end.localidade ?? ''
@ -107,7 +89,6 @@ watch(() => form.value.cep, (v) => {
if (v.replace(/\D/g, '').length === 8) buscarCep() if (v.replace(/\D/g, '').length === 8) buscarCep()
}) })
// Navegação
function avancar() { function avancar() {
erro.value = '' erro.value = ''
etapa.value++ etapa.value++
@ -118,7 +99,6 @@ function voltar() {
etapa.value-- etapa.value--
} }
// Submissão final
async function solicitar() { async function solicitar() {
carregando.value = true carregando.value = true
erro.value = '' erro.value = ''
@ -150,10 +130,9 @@ async function solicitar() {
cargo: form.value.representanteCargo, cargo: form.value.representanteCargo,
} : undefined, } : undefined,
}) })
const ultimaEtapa = isPJ.value ? 6 : 5 etapa.value = totalEtapas.value
etapa.value = ultimaEtapa
} catch (e) { } catch (e) {
erro.value = e.response?.data?.description ?? 'Erro ao enviar solicitação. Tente novamente.' erro.value = e?.data?.description ?? 'Erro ao enviar solicitação. Tente novamente.'
} finally { } finally {
carregando.value = false carregando.value = false
} }
@ -178,7 +157,6 @@ const estados = ['AC','AL','AP','AM','BA','CE','DF','ES','GO','MA','MT','MS','MG
<div class="min-h-[calc(100vh-8rem)] flex items-center justify-center px-4 py-12"> <div class="min-h-[calc(100vh-8rem)] flex items-center justify-center px-4 py-12">
<div class="w-full max-w-lg"> <div class="w-full max-w-lg">
<!-- Barra de progresso -->
<div v-if="etapa < totalEtapas" class="mb-8"> <div v-if="etapa < totalEtapas" class="mb-8">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<p class="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide"> <p class="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">
@ -196,7 +174,6 @@ const estados = ['AC','AL','AP','AM','BA','CE','DF','ES','GO','MA','MT','MS','MG
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-lg border border-slate-200 dark:border-slate-700 overflow-hidden"> <div class="bg-white dark:bg-slate-800 rounded-2xl shadow-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
<!-- Header -->
<div class="bg-gradient-to-r from-primary to-primary-700 px-8 py-6"> <div class="bg-gradient-to-r from-primary to-primary-700 px-8 py-6">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="w-10 h-10 bg-white/20 rounded-xl flex items-center justify-center" aria-hidden="true"> <div class="w-10 h-10 bg-white/20 rounded-xl flex items-center justify-center" aria-hidden="true">
@ -213,7 +190,6 @@ const estados = ['AC','AL','AP','AM','BA','CE','DF','ES','GO','MA','MT','MS','MG
<div class="px-8 py-8 space-y-5"> <div class="px-8 py-8 space-y-5">
<!-- ETAPA 0 DOCUMENTO -->
<template v-if="etapa === 0"> <template v-if="etapa === 0">
<p class="text-sm text-slate-600 dark:text-slate-300"> <p class="text-sm text-slate-600 dark:text-slate-300">
Informe seu CPF (pessoa física) ou CNPJ (empresa) para iniciar o credenciamento. Informe seu CPF (pessoa física) ou CNPJ (empresa) para iniciar o credenciamento.
@ -235,11 +211,10 @@ const estados = ['AC','AL','AP','AM','BA','CE','DF','ES','GO','MA','MT','MS','MG
/> />
<p class="text-center text-sm text-slate-500 dark:text-slate-400"> <p class="text-center text-sm text-slate-500 dark:text-slate-400">
tem cadastro? tem cadastro?
<RouterLink :to="{ name: 'home' }" class="text-primary font-semibold hover:underline">Entrar</RouterLink> <NuxtLink to="/" class="text-primary font-semibold hover:underline">Entrar</NuxtLink>
</p> </p>
</template> </template>
<!-- ETAPA 1 DADOS PESSOAIS -->
<template v-else-if="etapa === 1"> <template v-else-if="etapa === 1">
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
@ -267,7 +242,6 @@ const estados = ['AC','AL','AP','AM','BA','CE','DF','ES','GO','MA','MT','MS','MG
</div> </div>
</template> </template>
<!-- ETAPA 2 ENDEREÇO -->
<template v-else-if="etapa === 2"> <template v-else-if="etapa === 2">
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
@ -314,7 +288,6 @@ const estados = ['AC','AL','AP','AM','BA','CE','DF','ES','GO','MA','MT','MS','MG
</div> </div>
</template> </template>
<!-- ETAPA 3 CONTATO -->
<template v-else-if="etapa === 3"> <template v-else-if="etapa === 3">
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
@ -348,7 +321,6 @@ const estados = ['AC','AL','AP','AM','BA','CE','DF','ES','GO','MA','MT','MS','MG
</div> </div>
</template> </template>
<!-- ETAPA 4 REPRESENTANTE LEGAL ( PJ) -->
<template v-else-if="etapa === 4 && isPJ"> <template v-else-if="etapa === 4 && isPJ">
<p class="text-sm text-slate-600 dark:text-slate-300"> <p class="text-sm text-slate-600 dark:text-slate-300">
Informe os dados do representante legal da empresa que será responsável pelo acesso ao portal. Informe os dados do representante legal da empresa que será responsável pelo acesso ao portal.
@ -375,7 +347,6 @@ const estados = ['AC','AL','AP','AM','BA','CE','DF','ES','GO','MA','MT','MS','MG
</div> </div>
</template> </template>
<!-- REVISÃO -->
<template v-else-if="(isPJ && etapa === 5) || (!isPJ && etapa === 4)"> <template v-else-if="(isPJ && etapa === 5) || (!isPJ && etapa === 4)">
<p class="text-sm text-slate-600 dark:text-slate-300 mb-2">Confira os dados antes de enviar a solicitação.</p> <p class="text-sm text-slate-600 dark:text-slate-300 mb-2">Confira os dados antes de enviar a solicitação.</p>
<div class="space-y-3 text-sm"> <div class="space-y-3 text-sm">
@ -404,7 +375,6 @@ const estados = ['AC','AL','AP','AM','BA','CE','DF','ES','GO','MA','MT','MS','MG
</div> </div>
</template> </template>
<!-- SUCESSO -->
<template v-else> <template v-else>
<div class="text-center py-4"> <div class="text-center py-4">
<div class="w-16 h-16 bg-emerald-100 dark:bg-emerald-900/30 rounded-2xl flex items-center justify-center mx-auto mb-4"> <div class="w-16 h-16 bg-emerald-100 dark:bg-emerald-900/30 rounded-2xl flex items-center justify-center mx-auto mb-4">
@ -417,17 +387,16 @@ const estados = ['AC','AL','AP','AM','BA','CE','DF','ES','GO','MA','MT','MS','MG
Você receberá um e-mail em <strong class="text-slate-700 dark:text-slate-300">{{ form.email }}</strong> com o resultado. Você receberá um e-mail em <strong class="text-slate-700 dark:text-slate-300">{{ form.email }}</strong> com o resultado.
</p> </p>
</div> </div>
<Button label="Voltar à página inicial" icon="pi pi-home" class="w-full" size="large" @click="router.push({ name: 'home' })" /> <Button label="Voltar à página inicial" icon="pi pi-home" class="w-full" size="large" @click="router.push('/')" />
</template> </template>
</div> </div>
</div> </div>
<!-- Link voltar para home -->
<div v-if="etapa === 0" class="text-center mt-4"> <div v-if="etapa === 0" class="text-center mt-4">
<button <button
class="inline-flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 transition-colors py-3 px-4 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800" class="inline-flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 transition-colors py-3 px-4 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800"
@click="router.push({ name: 'home' })" @click="router.push('/')"
> >
<i class="pi pi-arrow-left text-xs" aria-hidden="true" /> <i class="pi pi-arrow-left text-xs" aria-hidden="true" />
Voltar à página inicial Voltar à página inicial

View File

@ -1,10 +1,9 @@
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { usePrefeituraStore } from '@/stores/prefeituraStore' import { usePrefeituraStore } from '@/stores/prefeituraStore'
import { useAuth } from '@/composables/useAuth'
import { useFocusLoginInput } from '@/composables/useFocusLoginInput'
import { useMotion } from '@/composables/useMotion' import { useMotion } from '@/composables/useMotion'
import DocumentoInput from '@/components/auth/DocumentoInput.vue'
import ServiceCard from '@/components/common/ServiceCard.vue'
import bgTutoia from '@/assets/images/bg-tutoia.jpeg' import bgTutoia from '@/assets/images/bg-tutoia.jpeg'
@ -12,13 +11,30 @@ const { prefersReducedMotion } = useMotion()
const router = useRouter() const router = useRouter()
const prefeitura = usePrefeituraStore() const prefeitura = usePrefeituraStore()
const { isAuthenticated, nomeUsuario, login } = useAuth()
const { requested: focusLoginRequested, consume: consumeFocusLogin } = useFocusLoginInput()
const documento = ref('') const documento = ref('')
const erro = ref('') const erro = ref('')
const carregando = ref(false)
// Ref ao DocumentoInput usado pelo botão "Entrar" do AppHeader pra focar o campo
const documentoRef = ref(null)
// Quando o AppHeader sinaliza intenção de login (clique no botão Entrar),
// foca o campo + scroll suave pra ele.
watch(focusLoginRequested, async (v) => {
if (!v || isAuthenticated.value) {
if (v) consumeFocusLogin()
return
}
await nextTick()
const el = documentoRef.value?.$el ?? documentoRef.value
el?.scrollIntoView?.({ behavior: 'smooth', block: 'center' })
documentoRef.value?.focus?.()
consumeFocusLogin()
}, { immediate: true })
// Hero background
// Mapa template imagem estática (Vite resolve na build).
// Para adicionar novo município: importar a foto e adicionar a chave abaixo.
const heroBgMap = { const heroBgMap = {
tutoia: bgTutoia, tutoia: bgTutoia,
} }
@ -37,8 +53,7 @@ const heroBgStyle = computed(() => {
const heroHasPhoto = computed(() => !!heroBgUrl.value) const heroHasPhoto = computed(() => !!heroBgUrl.value)
// Avisos (carousel) // Dados mockados conectar ao endpoint /publico/avisos/{dominio} futuramente
// Dados mockados conectar ao endpoint /api/v1/publico/avisos/{dominio} futuramente
const avisos = ref([ const avisos = ref([
{ {
id: 1, id: 1,
@ -47,7 +62,7 @@ const avisos = ref([
titulo: 'IPTU 2025 — Parcela única com desconto', titulo: 'IPTU 2025 — Parcela única com desconto',
descricao: 'Pague até 31/07 e ganhe 10% de desconto. Emita seu boleto agora mesmo.', descricao: 'Pague até 31/07 e ganhe 10% de desconto. Emita seu boleto agora mesmo.',
cor: 'amber', cor: 'amber',
acao: { label: 'Emitir boleto', to: { name: 'iptu' } }, acao: { label: 'Emitir boleto', to: '/servicos/iptu' },
}, },
{ {
id: 2, id: 2,
@ -56,7 +71,7 @@ const avisos = ref([
titulo: 'Novo serviço: Certidão Online Instantânea', titulo: 'Novo serviço: Certidão Online Instantânea',
descricao: 'Emita certidões negativas em segundos, sem sair de casa e com validade legal.', descricao: 'Emita certidões negativas em segundos, sem sair de casa e com validade legal.',
cor: 'green', cor: 'green',
acao: { label: 'Emitir agora', to: { name: 'certidao' } }, acao: { label: 'Emitir agora', to: '/servicos/certidao' },
}, },
{ {
id: 3, id: 3,
@ -75,28 +90,35 @@ const corAviso = {
blue: { bg: 'bg-blue-50 dark:bg-blue-900/20', borda: 'border-blue-200 dark:border-blue-700/40', icone: 'text-blue-600 dark:text-blue-400', tag: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300' }, blue: { bg: 'bg-blue-50 dark:bg-blue-900/20', borda: 'border-blue-200 dark:border-blue-700/40', icone: 'text-blue-600 dark:text-blue-400', tag: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300' },
} }
// Serviços
const servicosPublicos = [ const servicosPublicos = [
{ icon: 'pi-file-check', titulo: 'Emissão de Certidão', descricao: 'Certidão negativa, positiva e situação fiscal.', to: { name: 'certidao' } }, { icon: 'pi-file-check', titulo: 'Emissão de Certidão', descricao: 'Certidão negativa, positiva e situação fiscal.', to: '/servicos/certidao' },
{ icon: 'pi-home', titulo: 'IPTU — Débitos e Carnê', descricao: 'Consulte débitos e imprima o carnê de pagamento.', to: { name: 'iptu' } }, { icon: 'pi-home', titulo: 'IPTU — Débitos e Carnê', descricao: 'Consulte débitos e imprima o carnê de pagamento.', to: '/servicos/iptu' },
{ icon: 'pi-verified', titulo: 'Validar Documento', descricao: 'Verifique a autenticidade de certidões emitidas.', to: { name: 'servicos' } }, { icon: 'pi-verified', titulo: 'Validar Documento', descricao: 'Verifique a autenticidade de certidões emitidas.', to: '/servicos' },
] ]
const servicosAutenticados = [ const servicosAutenticados = [
{ icon: 'pi-receipt', titulo: 'Débitos e Guias', descricao: 'Emita guias de pagamento de todos os seus tributos.', to: { name: 'debitos' } }, { icon: 'pi-receipt', titulo: 'Débitos e Guias', descricao: 'Emita guias de pagamento de todos os seus tributos.', to: '/portal/debitos' },
{ icon: 'pi-file-edit', titulo: 'Certidões Ativas', descricao: 'Acesse e reemita certidões emitidas anteriormente.', to: { name: 'certidoes-portal' } }, { icon: 'pi-file-edit', titulo: 'Certidões Ativas', descricao: 'Acesse e reemita certidões emitidas anteriormente.', to: '/portal/certidoes' },
{ icon: 'pi-briefcase', titulo: 'Alvarás', descricao: 'Acompanhe processos e solicite novos alvarás.', to: { name: 'alvaras' } }, { icon: 'pi-briefcase', titulo: 'Alvarás', descricao: 'Acompanhe processos e solicite novos alvarás.', to: '/portal/alvaras' },
{ icon: 'pi-credit-card', titulo: 'Pagamentos', descricao: 'Histórico completo com comprovantes para download.', to: { name: 'pagamentos' } }, { icon: 'pi-credit-card', titulo: 'Pagamentos', descricao: 'Histórico completo com comprovantes para download.', to: '/portal/pagamentos' },
{ icon: 'pi-user', titulo: 'Dados Cadastrais', descricao: 'Visualize e mantenha seus dados sempre atualizados.', to: { name: 'dados' } }, { icon: 'pi-user', titulo: 'Dados Cadastrais', descricao: 'Visualize e mantenha seus dados sempre atualizados.', to: '/portal/dados' },
] ]
function continuar() { async function continuar() {
if (documento.value.replace(/\D/g, '').length < 11) { const doc = documento.value.replace(/\D/g, '')
if (doc.length < 11) {
erro.value = 'Informe um CPF (11 dígitos) ou CNPJ (14 dígitos) válido.' erro.value = 'Informe um CPF (11 dígitos) ou CNPJ (14 dígitos) válido.'
return return
} }
erro.value = '' erro.value = ''
router.push({ name: 'login', query: { doc: documento.value } }) carregando.value = true
try {
await login(doc, '/portal/painel')
// login() faz window.location não retorna aqui em condições normais
} catch (e) {
carregando.value = false
erro.value = e?.data?.statusMessage ?? 'Não foi possível iniciar o login.'
}
} }
</script> </script>
@ -109,15 +131,12 @@ function continuar() {
:class="heroHasPhoto ? '' : 'bg-gradient-to-br from-slate-900 via-primary-900 to-slate-800'" :class="heroHasPhoto ? '' : 'bg-gradient-to-br from-slate-900 via-primary-900 to-slate-800'"
:style="heroBgStyle" :style="heroBgStyle"
> >
<!-- Padrão geométrico sutil (visível sem foto) -->
<div <div
v-if="!heroHasPhoto" v-if="!heroHasPhoto"
class="absolute inset-0 opacity-5 pointer-events-none" class="absolute inset-0 opacity-5 pointer-events-none"
style="background-image: radial-gradient(circle at 25px 25px, white 2px, transparent 0); background-size: 50px 50px;" style="background-image: radial-gradient(circle at 25px 25px, white 2px, transparent 0); background-size: 50px 50px;"
/> />
<!-- Gradiente lateral esquerdo garante legibilidade dos textos
independente do conteúdo da foto de fundo -->
<div <div
v-if="heroHasPhoto" v-if="heroHasPhoto"
class="absolute inset-0 pointer-events-none" class="absolute inset-0 pointer-events-none"
@ -127,9 +146,7 @@ function continuar() {
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-14 lg:py-20"> <div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-14 lg:py-20">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-20 items-center"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-20 items-center">
<!-- Esquerda -->
<div> <div>
<!-- Identidade do município -->
<div class="flex items-center gap-3 mb-6"> <div class="flex items-center gap-3 mb-6">
<img <img
v-if="prefeitura.pathLogo" v-if="prefeitura.pathLogo"
@ -160,7 +177,6 @@ function continuar() {
da prefeitura sem precisar sair de casa. da prefeitura sem precisar sair de casa.
</p> </p>
<!-- Lista de serviços públicos -->
<ul class="space-y-2 mb-8"> <ul class="space-y-2 mb-8">
<li <li
v-for="s in servicosPublicos" v-for="s in servicosPublicos"
@ -177,20 +193,47 @@ function continuar() {
</li> </li>
</ul> </ul>
<RouterLink <NuxtLink
:to="{ name: 'servicos' }" to="/servicos"
class="inline-flex items-center gap-2 text-sm text-white/80 hover:text-white transition-colors font-medium" class="inline-flex items-center gap-2 text-sm text-white/80 hover:text-white transition-colors font-medium"
> >
Ver todos os serviços disponíveis Ver todos os serviços disponíveis
<i class="pi pi-arrow-right text-xs" /> <i class="pi pi-arrow-right text-xs" />
</RouterLink> </NuxtLink>
</div> </div>
<!-- Direita Card de acesso -->
<div class="flex justify-center lg:justify-end"> <div class="flex justify-center lg:justify-end">
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl p-8 w-full max-w-sm backdrop-blur-sm"> <!-- Card de saudação (usuário autenticado) -->
<div v-if="isAuthenticated" class="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl p-8 w-full max-w-sm backdrop-blur-sm">
<div class="flex items-center gap-3 mb-6">
<div class="w-11 h-11 bg-primary rounded-xl flex items-center justify-center shadow-sm">
<i class="pi pi-user text-white text-lg" />
</div>
<div class="min-w-0">
<p class="text-xs text-slate-500 dark:text-slate-400">Bem-vindo(a) de volta</p>
<p class="font-bold text-slate-800 dark:text-slate-100 text-base truncate">{{ nomeUsuario || 'Contribuinte' }}</p>
</div>
</div>
<p class="text-sm text-slate-600 dark:text-slate-300 mb-6 leading-relaxed">
Você está autenticado. Acesse seu painel para emitir guias,
consultar débitos e gerenciar seus serviços.
</p>
<NuxtLink to="/portal/painel" class="block">
<Button
label="Ir para o painel"
icon="pi pi-arrow-right"
icon-pos="right"
class="w-full"
size="large"
/>
</NuxtLink>
</div>
<!-- Card de acesso (visitante) -->
<div v-else class="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl p-8 w-full max-w-sm backdrop-blur-sm">
<!-- Cabeçalho do card -->
<div class="flex items-center gap-3 mb-7"> <div class="flex items-center gap-3 mb-7">
<div class="w-11 h-11 bg-primary rounded-xl flex items-center justify-center shadow-sm"> <div class="w-11 h-11 bg-primary rounded-xl flex items-center justify-center shadow-sm">
<i class="pi pi-lock-open text-white text-lg" /> <i class="pi pi-lock-open text-white text-lg" />
@ -201,13 +244,13 @@ function continuar() {
</div> </div>
</div> </div>
<!-- Formulário -->
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-200 mb-1.5"> <label class="block text-sm font-medium text-slate-700 dark:text-slate-200 mb-1.5">
CPF ou CNPJ CPF ou CNPJ
</label> </label>
<DocumentoInput <DocumentoInput
ref="documentoRef"
v-model="documento" v-model="documento"
@keyup.enter="continuar" @keyup.enter="continuar"
/> />
@ -223,6 +266,7 @@ function continuar() {
icon-pos="right" icon-pos="right"
class="w-full" class="w-full"
size="large" size="large"
:loading="carregando"
@click="continuar" @click="continuar"
/> />
</div> </div>
@ -232,19 +276,19 @@ function continuar() {
</Divider> </Divider>
<div class="space-y-2.5 text-center"> <div class="space-y-2.5 text-center">
<RouterLink <NuxtLink
:to="{ name: 'primeiro-acesso' }" to="/primeiro-acesso"
class="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg border border-slate-200 dark:border-slate-600 text-sm text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700 hover:border-slate-300 dark:hover:border-slate-500 transition-colors font-medium" class="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg border border-slate-200 dark:border-slate-600 text-sm text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700 hover:border-slate-300 dark:hover:border-slate-500 transition-colors font-medium"
> >
<i class="pi pi-key text-slate-500 dark:text-slate-400 text-sm" /> <i class="pi pi-key text-slate-500 dark:text-slate-400 text-sm" />
Criar minha senha Criar minha senha
</RouterLink> </NuxtLink>
<RouterLink <NuxtLink
:to="{ name: 'credenciamento' }" to="/credenciamento"
class="block text-xs text-slate-400 dark:text-slate-500 hover:text-slate-600 dark:hover:text-slate-300 transition-colors" class="block text-xs text-slate-400 dark:text-slate-500 hover:text-slate-600 dark:hover:text-slate-300 transition-colors"
> >
Ainda não cadastrado? <span class="text-primary font-semibold">Credenciar-se</span> Ainda não cadastrado? <span class="text-primary font-semibold">Credenciar-se</span>
</RouterLink> </NuxtLink>
</div> </div>
</div> </div>
@ -257,7 +301,6 @@ function continuar() {
<!-- CAROUSEL DE AVISOS --> <!-- CAROUSEL DE AVISOS -->
<section class="bg-white dark:bg-slate-900 border-b border-slate-100 dark:border-slate-800" aria-label="Avisos e comunicados"> <section class="bg-white dark:bg-slate-900 border-b border-slate-100 dark:border-slate-800" aria-label="Avisos e comunicados">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<!-- autoplay desativado quando prefers-reduced-motion está ativo -->
<Carousel <Carousel
:value="avisos" :value="avisos"
:num-visible="1" :num-visible="1"
@ -275,17 +318,13 @@ function continuar() {
corAviso[aviso.cor].borda, corAviso[aviso.cor].borda,
]" ]"
> >
<div <div class="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 bg-white shadow-sm">
:class="[
'w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 bg-white shadow-sm',
]"
>
<i :class="['pi', aviso.icone, 'text-lg', corAviso[aviso.cor].icone]" /> <i :class="['pi', aviso.icone, 'text-lg', corAviso[aviso.cor].icone]" />
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="font-semibold text-slate-800 dark:text-slate-100 text-sm">{{ aviso.titulo }}</p> <p class="font-semibold text-slate-800 dark:text-slate-100 text-sm">{{ aviso.titulo }}</p>
<p class="text-xs text-slate-600 dark:text-slate-300 mt-0.5 leading-relaxed">{{ aviso.descricao }}</p> <p class="text-xs text-slate-600 dark:text-slate-300 mt-0.5 leading-relaxed">{{ aviso.descricao }}</p>
<RouterLink <NuxtLink
v-if="aviso.acao" v-if="aviso.acao"
:to="aviso.acao.to" :to="aviso.acao.to"
class="inline-block mt-3" class="inline-block mt-3"
@ -296,7 +335,7 @@ function continuar() {
outlined outlined
class="whitespace-nowrap" class="whitespace-nowrap"
/> />
</RouterLink> </NuxtLink>
</div> </div>
</div> </div>
</template> </template>
@ -322,11 +361,10 @@ function continuar() {
v-for="s in servicosAutenticados" v-for="s in servicosAutenticados"
:key="s.titulo" :key="s.titulo"
v-bind="s" v-bind="s"
:require-auth="true" :requires-auth="true"
/> />
</div> </div>
<!-- CTA credenciamento -->
<div class="mt-10 bg-gradient-to-r from-primary/5 to-primary/10 dark:from-primary/10 dark:to-primary/15 border border-primary/15 dark:border-primary/20 rounded-2xl p-6 flex flex-col sm:flex-row items-center justify-between gap-5"> <div class="mt-10 bg-gradient-to-r from-primary/5 to-primary/10 dark:from-primary/10 dark:to-primary/15 border border-primary/15 dark:border-primary/20 rounded-2xl p-6 flex flex-col sm:flex-row items-center justify-between gap-5">
<div> <div>
<p class="font-bold text-slate-800 dark:text-slate-100 text-base">Ainda não tem acesso ao portal?</p> <p class="font-bold text-slate-800 dark:text-slate-100 text-base">Ainda não tem acesso ao portal?</p>
@ -334,9 +372,9 @@ function continuar() {
Solicite seu credenciamento e passe a gerenciar todos os seus tributos municipais de forma online. Solicite seu credenciamento e passe a gerenciar todos os seus tributos municipais de forma online.
</p> </p>
</div> </div>
<RouterLink :to="{ name: 'credenciamento' }"> <NuxtLink to="/credenciamento">
<Button label="Solicitar Credenciamento" icon="pi pi-user-plus" class="whitespace-nowrap" /> <Button label="Solicitar Credenciamento" icon="pi pi-user-plus" class="whitespace-nowrap" />
</RouterLink> </NuxtLink>
</div> </div>
</section> </section>
@ -344,7 +382,6 @@ function continuar() {
</template> </template>
<style scoped> <style scoped>
/* Remove as setas de navegação padrão do Carousel no contexto dos avisos */
.aviso-carousel :deep(.p-carousel-prev), .aviso-carousel :deep(.p-carousel-prev),
.aviso-carousel :deep(.p-carousel-next) { .aviso-carousel :deep(.p-carousel-next) {
width: 1.75rem; width: 1.75rem;

View File

@ -2,6 +2,11 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { portalService } from '@/services/portalService' import { portalService } from '@/services/portalService'
definePageMeta({
layout: 'portal',
middleware: 'auth',
})
const alvaras = ref([]) const alvaras = ref([])
const carregando = ref(true) const carregando = ref(true)
const mensagemErro = ref('') const mensagemErro = ref('')
@ -30,10 +35,10 @@ async function carregar() {
mensagemErro.value = '' mensagemErro.value = ''
try { try {
const params = filtroStatus.value ? { status: filtroStatus.value } : {} const params = filtroStatus.value ? { status: filtroStatus.value } : {}
const { data } = await portalService.getAlvaras(params) const res = await portalService.getAlvaras(params)
alvaras.value = data.data?.content ?? [] alvaras.value = res.data?.content ?? []
} catch (e) { } catch (e) {
mensagemErro.value = e.response?.data?.description ?? 'Não foi possível carregar os alvarás.' mensagemErro.value = e?.data?.description ?? 'Não foi possível carregar os alvarás.'
} finally { } finally {
carregando.value = false carregando.value = false
} }
@ -47,7 +52,6 @@ async function carregar() {
<p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">Acompanhe o andamento dos seus processos de alvará.</p> <p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">Acompanhe o andamento dos seus processos de alvará.</p>
</div> </div>
<!-- Filtro -->
<div class="flex gap-3 flex-wrap"> <div class="flex gap-3 flex-wrap">
<button <button
:class="['px-4 py-2 rounded-lg text-sm font-semibold border transition-colors', filtroStatus === null ? 'bg-primary text-white border-primary' : 'bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-700 hover:border-slate-300']" :class="['px-4 py-2 rounded-lg text-sm font-semibold border transition-colors', filtroStatus === null ? 'bg-primary text-white border-primary' : 'bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-700 hover:border-slate-300']"
@ -108,7 +112,6 @@ async function carregar() {
</div> </div>
</div> </div>
<!-- Timeline de etapas -->
<div v-if="alv.etapas?.length" class="mt-4 flex items-center gap-1 overflow-x-auto pb-1"> <div v-if="alv.etapas?.length" class="mt-4 flex items-center gap-1 overflow-x-auto pb-1">
<template v-for="(etapa, idx) in alv.etapas" :key="idx"> <template v-for="(etapa, idx) in alv.etapas" :key="idx">
<div class="flex flex-col items-center gap-1 min-w-[72px]"> <div class="flex flex-col items-center gap-1 min-w-[72px]">

View File

@ -1,8 +1,12 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { portalService } from '@/services/portalService' import { portalService } from '@/services/portalService'
definePageMeta({
layout: 'portal',
middleware: 'auth',
})
const router = useRouter() const router = useRouter()
const certidoes = ref([]) const certidoes = ref([])
const carregando = ref(true) const carregando = ref(true)
@ -15,10 +19,10 @@ async function carregar() {
carregando.value = true carregando.value = true
mensagemErro.value = '' mensagemErro.value = ''
try { try {
const { data } = await portalService.getCertidoes() const res = await portalService.getCertidoes()
certidoes.value = data.data?.content ?? [] certidoes.value = res.data?.content ?? []
} catch (e) { } catch (e) {
mensagemErro.value = e.response?.data?.description ?? 'Não foi possível carregar as certidões.' mensagemErro.value = e?.data?.description ?? 'Não foi possível carregar as certidões.'
} finally { } finally {
carregando.value = false carregando.value = false
} }
@ -27,8 +31,8 @@ async function carregar() {
async function reemitir(cert) { async function reemitir(cert) {
carregandoPdf.value = cert.id carregandoPdf.value = cert.id
try { try {
const { data } = await portalService.reemitirCertidao(cert.id) const buf = await portalService.reemitirCertidao(cert.id)
const url = URL.createObjectURL(new Blob([data], { type: 'application/pdf' })) const url = URL.createObjectURL(new Blob([buf], { type: 'application/pdf' }))
const a = document.createElement('a') const a = document.createElement('a')
a.href = url a.href = url
a.download = `certidao-${cert.numero}.pdf` a.download = `certidao-${cert.numero}.pdf`
@ -55,7 +59,7 @@ const statusMap = {
<h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100">Certidões</h1> <h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100">Certidões</h1>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">Suas certidões emitidas e disponíveis para reemissão.</p> <p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">Suas certidões emitidas e disponíveis para reemissão.</p>
</div> </div>
<Button label="Nova certidão" icon="pi pi-plus" size="small" @click="router.push({ name: 'certidao' })" /> <Button label="Nova certidão" icon="pi pi-plus" size="small" @click="router.push('/servicos/certidao')" />
</div> </div>
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden"> <div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
@ -80,7 +84,7 @@ const statusMap = {
<i class="pi pi-file text-slate-300 dark:text-slate-600 text-4xl mb-3 block" aria-hidden="true" /> <i class="pi pi-file text-slate-300 dark:text-slate-600 text-4xl mb-3 block" aria-hidden="true" />
<p class="font-semibold text-slate-700 dark:text-slate-200">Nenhuma certidão emitida</p> <p class="font-semibold text-slate-700 dark:text-slate-200">Nenhuma certidão emitida</p>
<p class="text-sm text-slate-400 dark:text-slate-500 mt-1 mb-4">Emita sua primeira certidão pelo portal público.</p> <p class="text-sm text-slate-400 dark:text-slate-500 mt-1 mb-4">Emita sua primeira certidão pelo portal público.</p>
<Button label="Emitir certidão" size="small" @click="router.push({ name: 'certidao' })" /> <Button label="Emitir certidão" size="small" @click="router.push('/servicos/certidao')" />
</div> </div>
<div v-else> <div v-else>

View File

@ -2,6 +2,11 @@
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { portalService } from '@/services/portalService' import { portalService } from '@/services/portalService'
definePageMeta({
layout: 'portal',
middleware: 'auth',
})
const carregando = ref(true) const carregando = ref(true)
const salvando = ref(false) const salvando = ref(false)
const modoEditar = ref(false) const modoEditar = ref(false)
@ -13,13 +18,13 @@ const contato = reactive({ email: '', telefone: '', whatsapp: false })
onMounted(async () => { onMounted(async () => {
try { try {
const { data } = await portalService.getDadosCadastrais() const res = await portalService.getDadosCadastrais()
dados.value = data.data dados.value = res.data
contato.email = dados.value?.email ?? '' contato.email = dados.value?.email ?? ''
contato.telefone = dados.value?.telefone ?? '' contato.telefone = dados.value?.telefone ?? ''
contato.whatsapp = dados.value?.whatsapp ?? false contato.whatsapp = dados.value?.whatsapp ?? false
} catch (e) { } catch (e) {
mensagemErro.value = e.response?.data?.description ?? 'Não foi possível carregar os dados cadastrais.' mensagemErro.value = e?.data?.description ?? 'Não foi possível carregar os dados cadastrais.'
} finally { } finally {
carregando.value = false carregando.value = false
} }
@ -41,7 +46,7 @@ async function salvarContato() {
mensagemSucesso.value = 'Dados de contato atualizados com sucesso!' mensagemSucesso.value = 'Dados de contato atualizados com sucesso!'
modoEditar.value = false modoEditar.value = false
} catch (e) { } catch (e) {
mensagemErro.value = e.response?.data?.description ?? 'Erro ao salvar. Tente novamente.' mensagemErro.value = e?.data?.description ?? 'Erro ao salvar. Tente novamente.'
} finally { } finally {
salvando.value = false salvando.value = false
} }
@ -71,7 +76,6 @@ function formatarTelefone(e) {
<p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">Visualize seus dados e mantenha o contato atualizado.</p> <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>
<!-- Loading -->
<div v-if="carregando" class="space-y-4"> <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 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="h-4 bg-slate-200 dark:bg-slate-700 rounded animate-pulse w-1/4" />
@ -84,7 +88,6 @@ function formatarTelefone(e) {
<template v-else-if="dados"> <template v-else-if="dados">
<!-- Dados gerais (somente leitura) -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6"> <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> <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 class="grid grid-cols-1 sm:grid-cols-2 gap-4">
@ -107,7 +110,6 @@ function formatarTelefone(e) {
</div> </div>
</div> </div>
<!-- Endereço (somente leitura) -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6"> <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-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"> <p class="text-sm text-slate-800 dark:text-slate-100">
@ -123,7 +125,6 @@ function formatarTelefone(e) {
</p> </p>
</div> </div>
<!-- Contato (editável) -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6"> <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"> <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> <p class="text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-wide">Contato</p>
@ -137,7 +138,6 @@ function formatarTelefone(e) {
/> />
</div> </div>
<!-- Modo visualização -->
<div v-if="!modoEditar" class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div v-if="!modoEditar" class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<p class="text-xs text-slate-400 dark:text-slate-500">E-mail</p> <p class="text-xs text-slate-400 dark:text-slate-500">E-mail</p>
@ -152,7 +152,6 @@ function formatarTelefone(e) {
</div> </div>
</div> </div>
<!-- Modo edição -->
<div v-else class="space-y-4"> <div v-else class="space-y-4">
<div> <div>
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">E-mail</label> <label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">E-mail</label>
@ -177,7 +176,6 @@ function formatarTelefone(e) {
</div> </div>
</div> </div>
<!-- Sucesso -->
<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"> <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 }} <i class="pi pi-check-circle" aria-hidden="true" /> {{ mensagemSucesso }}
</p> </p>
@ -185,7 +183,6 @@ function formatarTelefone(e) {
</template> </template>
<!-- Erro global -->
<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"> <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> <p class="text-sm text-slate-600 dark:text-slate-300">{{ mensagemErro }}</p>
</div> </div>

View File

@ -2,6 +2,11 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { portalService } from '@/services/portalService' import { portalService } from '@/services/portalService'
definePageMeta({
layout: 'portal',
middleware: 'auth',
})
const debitos = ref([]) const debitos = ref([])
const carregando = ref(true) const carregando = ref(true)
const carregandoGuia = ref(null) const carregandoGuia = ref(null)
@ -28,10 +33,10 @@ async function carregar() {
const params = {} const params = {}
if (filtroTipo.value) params.tipo = filtroTipo.value if (filtroTipo.value) params.tipo = filtroTipo.value
if (filtroStatus.value) params.status = filtroStatus.value if (filtroStatus.value) params.status = filtroStatus.value
const { data } = await portalService.getDebitos(params) const res = await portalService.getDebitos(params)
debitos.value = data.data?.content ?? [] debitos.value = res.data?.content ?? []
} catch (e) { } catch (e) {
mensagemErro.value = e.response?.data?.description ?? 'Não foi possível carregar os débitos.' mensagemErro.value = e?.data?.description ?? 'Não foi possível carregar os débitos.'
} finally { } finally {
carregando.value = false carregando.value = false
} }
@ -40,8 +45,8 @@ async function carregar() {
async function emitirGuia(debito) { async function emitirGuia(debito) {
carregandoGuia.value = debito.id carregandoGuia.value = debito.id
try { try {
const { data } = await portalService.emitirGuia(debito.id) const buf = await portalService.emitirGuia(debito.id)
const url = URL.createObjectURL(new Blob([data], { type: 'application/pdf' })) const url = URL.createObjectURL(new Blob([buf], { type: 'application/pdf' }))
const a = document.createElement('a') const a = document.createElement('a')
a.href = url a.href = url
a.download = `guia-${debito.id}.pdf` a.download = `guia-${debito.id}.pdf`
@ -84,7 +89,6 @@ function limparFiltros() {
<template> <template>
<div class="space-y-6"> <div class="space-y-6">
<!-- Cabeçalho -->
<div class="flex items-center justify-between gap-4 flex-wrap"> <div class="flex items-center justify-between gap-4 flex-wrap">
<div> <div>
<h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100">Débitos e Guias</h1> <h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100">Débitos e Guias</h1>
@ -92,7 +96,6 @@ function limparFiltros() {
</div> </div>
</div> </div>
<!-- Filtros -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4 flex flex-wrap gap-3 items-end"> <div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4 flex flex-wrap gap-3 items-end">
<div class="flex-1 min-w-[160px]"> <div class="flex-1 min-w-[160px]">
<label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1.5">Tipo</label> <label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1.5">Tipo</label>
@ -124,7 +127,6 @@ function limparFiltros() {
</div> </div>
</div> </div>
<!-- Barra de ação para selecionados -->
<Transition <Transition
enter-active-class="transition-all duration-200" enter-active-class="transition-all duration-200"
enter-from-class="opacity-0 -translate-y-2" enter-from-class="opacity-0 -translate-y-2"
@ -144,10 +146,8 @@ function limparFiltros() {
</div> </div>
</Transition> </Transition>
<!-- Tabela -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden"> <div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
<!-- Loading skeleton -->
<div v-if="carregando" class="divide-y divide-slate-100 dark:divide-slate-700"> <div v-if="carregando" class="divide-y divide-slate-100 dark:divide-slate-700">
<div v-for="i in 5" :key="i" class="p-5 flex items-center gap-4"> <div v-for="i in 5" :key="i" class="p-5 flex items-center gap-4">
<div class="w-4 h-4 bg-slate-200 dark:bg-slate-700 rounded animate-pulse" /> <div class="w-4 h-4 bg-slate-200 dark:bg-slate-700 rounded animate-pulse" />
@ -161,23 +161,19 @@ function limparFiltros() {
</div> </div>
</div> </div>
<!-- Vazio -->
<div v-else-if="debitos.length === 0 && !mensagemErro" class="p-12 text-center"> <div v-else-if="debitos.length === 0 && !mensagemErro" class="p-12 text-center">
<i class="pi pi-check-circle text-emerald-400 dark:text-emerald-500 text-4xl mb-3 block" aria-hidden="true" /> <i class="pi pi-check-circle text-emerald-400 dark:text-emerald-500 text-4xl mb-3 block" aria-hidden="true" />
<p class="font-semibold text-slate-700 dark:text-slate-200">Nenhum débito encontrado</p> <p class="font-semibold text-slate-700 dark:text-slate-200">Nenhum débito encontrado</p>
<p class="text-sm text-slate-400 dark:text-slate-500 mt-1">Sua situação fiscal está regularizada.</p> <p class="text-sm text-slate-400 dark:text-slate-500 mt-1">Sua situação fiscal está regularizada.</p>
</div> </div>
<!-- Erro -->
<div v-else-if="mensagemErro" class="p-8 text-center"> <div v-else-if="mensagemErro" class="p-8 text-center">
<i class="pi pi-exclamation-triangle text-amber-400 text-3xl mb-3 block" aria-hidden="true" /> <i class="pi pi-exclamation-triangle text-amber-400 text-3xl mb-3 block" aria-hidden="true" />
<p class="text-sm text-slate-600 dark:text-slate-300">{{ mensagemErro }}</p> <p class="text-sm text-slate-600 dark:text-slate-300">{{ mensagemErro }}</p>
<Button label="Tentar novamente" severity="secondary" size="small" class="mt-4" @click="carregar" /> <Button label="Tentar novamente" severity="secondary" size="small" class="mt-4" @click="carregar" />
</div> </div>
<!-- Lista -->
<div v-else> <div v-else>
<!-- Header da lista -->
<div class="flex items-center gap-4 px-5 py-3 bg-slate-50 dark:bg-slate-700/50 border-b border-slate-200 dark:border-slate-700 text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide"> <div class="flex items-center gap-4 px-5 py-3 bg-slate-50 dark:bg-slate-700/50 border-b border-slate-200 dark:border-slate-700 text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">
<Checkbox :binary="true" @change="toggleTodos($event.target.checked)" /> <Checkbox :binary="true" @change="toggleTodos($event.target.checked)" />
<span class="flex-1">Descrição</span> <span class="flex-1">Descrição</span>

View File

@ -2,6 +2,11 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { portalService } from '@/services/portalService' import { portalService } from '@/services/portalService'
definePageMeta({
layout: 'portal',
middleware: 'auth',
})
const pagamentos = ref([]) const pagamentos = ref([])
const carregando = ref(true) const carregando = ref(true)
const carregandoComprovante = ref(null) const carregandoComprovante = ref(null)
@ -16,10 +21,10 @@ async function carregar() {
carregando.value = true carregando.value = true
mensagemErro.value = '' mensagemErro.value = ''
try { try {
const { data } = await portalService.getPagamentos({ ano: filtroAno.value }) const res = await portalService.getPagamentos({ ano: filtroAno.value })
pagamentos.value = data.data?.content ?? [] pagamentos.value = res.data?.content ?? []
} catch (e) { } catch (e) {
mensagemErro.value = e.response?.data?.description ?? 'Não foi possível carregar os pagamentos.' mensagemErro.value = e?.data?.description ?? 'Não foi possível carregar os pagamentos.'
} finally { } finally {
carregando.value = false carregando.value = false
} }
@ -28,8 +33,8 @@ async function carregar() {
async function baixarComprovante(pag) { async function baixarComprovante(pag) {
carregandoComprovante.value = pag.id carregandoComprovante.value = pag.id
try { try {
const { data } = await portalService.getComprovante(pag.id) const buf = await portalService.getComprovante(pag.id)
const url = URL.createObjectURL(new Blob([data], { type: 'application/pdf' })) const url = URL.createObjectURL(new Blob([buf], { type: 'application/pdf' }))
const a = document.createElement('a') const a = document.createElement('a')
a.href = url a.href = url
a.download = `comprovante-${pag.id}.pdf` a.download = `comprovante-${pag.id}.pdf`
@ -61,7 +66,6 @@ const formaPagMap = {
<p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">Todos os seus pagamentos com comprovantes para download.</p> <p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">Todos os seus pagamentos com comprovantes para download.</p>
</div> </div>
<!-- Filtro de ano -->
<div class="flex gap-2 flex-wrap"> <div class="flex gap-2 flex-wrap">
<button <button
v-for="ano in anosDisponiveis" v-for="ano in anosDisponiveis"

View File

@ -1,10 +1,14 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useAuth } from '@/composables/useAuth'
import { useAuthStore } from '@/stores/authStore'
import { portalService } from '@/services/portalService' import { portalService } from '@/services/portalService'
const auth = useAuthStore() definePageMeta({
layout: 'portal',
middleware: 'auth',
})
const { nomeUsuario } = useAuth()
const router = useRouter() const router = useRouter()
const resumo = ref(null) const resumo = ref(null)
@ -17,8 +21,8 @@ onMounted(async () => {
portalService.getPainelResumo(), portalService.getPainelResumo(),
portalService.getAtividades(), portalService.getAtividades(),
]) ])
resumo.value = resResumo.data.data resumo.value = resResumo.data
atividades.value = resAtividades.data.data?.content ?? [] atividades.value = resAtividades.data?.content ?? []
} catch { } catch {
// silencioso exibe zeros // silencioso exibe zeros
} finally { } finally {
@ -27,10 +31,10 @@ onMounted(async () => {
}) })
const acesRapidos = [ const acesRapidos = [
{ icon: 'pi-receipt', label: 'Emitir Guia', to: 'debitos', cor: 'text-primary' }, { icon: 'pi-receipt', label: 'Emitir Guia', to: '/portal/debitos', cor: 'text-primary' },
{ icon: 'pi-file-check', label: 'Nova Certidão', to: 'certidoes-portal', cor: 'text-emerald-600 dark:text-emerald-400' }, { icon: 'pi-file-check', label: 'Nova Certidão', to: '/portal/certidoes', cor: 'text-emerald-600 dark:text-emerald-400' },
{ icon: 'pi-briefcase', label: 'Acompanhar Alvará', to: 'alvaras', cor: 'text-amber-600 dark:text-amber-400' }, { icon: 'pi-briefcase', label: 'Acompanhar Alvará', to: '/portal/alvaras', cor: 'text-amber-600 dark:text-amber-400' },
{ icon: 'pi-user', label: 'Meus Dados', to: 'dados', cor: 'text-violet-600 dark:text-violet-400' }, { icon: 'pi-user', label: 'Meus Dados', to: '/portal/dados', cor: 'text-violet-600 dark:text-violet-400' },
] ]
function formatarMoeda(valor) { function formatarMoeda(valor) {
@ -49,15 +53,13 @@ const iconeAtividade = {
<template> <template>
<div class="space-y-8"> <div class="space-y-8">
<!-- Saudação -->
<div> <div>
<h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100"> <h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100">
Olá, {{ auth.nomeUsuario || 'Contribuinte' }} 👋 Olá, {{ nomeUsuario || 'Contribuinte' }} 👋
</h1> </h1>
<p class="text-slate-500 dark:text-slate-400 mt-1">Bem-vindo ao seu painel de gestão fiscal.</p> <p class="text-slate-500 dark:text-slate-400 mt-1">Bem-vindo ao seu painel de gestão fiscal.</p>
</div> </div>
<!-- Cards de resumo -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5 space-y-3"> <div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5 space-y-3">
@ -114,7 +116,6 @@ const iconeAtividade = {
</div> </div>
<!-- Alerta de débitos vencidos -->
<div <div
v-if="!carregando && resumo?.debitosVencidos > 0" v-if="!carregando && resumo?.debitosVencidos > 0"
class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700/50 rounded-xl p-4 flex items-center gap-4" class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700/50 rounded-xl p-4 flex items-center gap-4"
@ -126,12 +127,11 @@ const iconeAtividade = {
</p> </p>
<p class="text-xs text-red-600 dark:text-red-400 mt-0.5">Regularize para evitar juros e negativação.</p> <p class="text-xs text-red-600 dark:text-red-400 mt-0.5">Regularize para evitar juros e negativação.</p>
</div> </div>
<Button label="Ver débitos" size="small" severity="danger" @click="router.push({ name: 'debitos' })" /> <Button label="Ver débitos" size="small" severity="danger" @click="router.push('/portal/debitos')" />
</div> </div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Acesso rápido -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6"> <div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6">
<p class="text-sm font-bold text-slate-700 dark:text-slate-200 mb-4">Acesso rápido</p> <p class="text-sm font-bold text-slate-700 dark:text-slate-200 mb-4">Acesso rápido</p>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
@ -139,7 +139,7 @@ const iconeAtividade = {
v-for="a in acesRapidos" v-for="a in acesRapidos"
:key="a.label" :key="a.label"
class="flex flex-col items-center gap-2 p-4 rounded-xl border border-slate-100 dark:border-slate-700 hover:border-primary/40 hover:bg-primary/5 dark:hover:bg-primary/10 transition-colors group" class="flex flex-col items-center gap-2 p-4 rounded-xl border border-slate-100 dark:border-slate-700 hover:border-primary/40 hover:bg-primary/5 dark:hover:bg-primary/10 transition-colors group"
@click="router.push({ name: a.to })" @click="router.push(a.to)"
> >
<i :class="['pi', a.icon, a.cor, 'text-xl']" aria-hidden="true" /> <i :class="['pi', a.icon, a.cor, 'text-xl']" aria-hidden="true" />
<span class="text-xs font-semibold text-slate-600 dark:text-slate-300 text-center leading-tight group-hover:text-primary transition-colors">{{ a.label }}</span> <span class="text-xs font-semibold text-slate-600 dark:text-slate-300 text-center leading-tight group-hover:text-primary transition-colors">{{ a.label }}</span>
@ -147,7 +147,6 @@ const iconeAtividade = {
</div> </div>
</div> </div>
<!-- Atividades recentes -->
<div class="lg:col-span-2 bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6"> <div class="lg:col-span-2 bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6">
<p class="text-sm font-bold text-slate-700 dark:text-slate-200 mb-4">Atividade recente</p> <p class="text-sm font-bold text-slate-700 dark:text-slate-200 mb-4">Atividade recente</p>

View File

@ -1,29 +1,24 @@
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useRouter } from 'vue-router' import { useAuth } from '@/composables/useAuth'
import { primeiroAcessoService } from '@/services/primeiroAcessoService' import { primeiroAcessoService } from '@/services/primeiroAcessoService'
import DocumentoInput from '@/components/auth/DocumentoInput.vue'
const router = useRouter() const router = useRouter()
const { login } = useAuth()
// Estado do wizard const etapa = ref(0)
const etapa = ref(0) // 0 identificação | 1 canal | 2 código | 3 senha | 4 sucesso
const carregando = ref(false) const carregando = ref(false)
const erro = ref('') const erro = ref('')
// Etapa 0 identificação
const documento = ref('') const documento = ref('')
const contribuinteNome = ref('') const contribuinteNome = ref('')
// Etapa 1 canal de envio do código const canais = ref([])
const canais = ref([]) // [{ tipo: 'EMAIL', valor: 'jo**@gmail.com' }, ...]
const canalSelecionado = ref(null) const canalSelecionado = ref(null)
// Etapa 2 código de verificação
const codigo = ref('') const codigo = ref('')
const tokenValidacao = ref('') const tokenValidacao = ref('')
// Etapa 3 senha
const senha = ref('') const senha = ref('')
const senhaConfirm = ref('') const senhaConfirm = ref('')
@ -33,20 +28,18 @@ const docValido = computed(() => docDigitos.value.length === 11 || docDigitos.va
const senhaForte = computed(() => senha.value.length >= 8) const senhaForte = computed(() => senha.value.length >= 8)
const senhasIguais = computed(() => senha.value === senhaConfirm.value) const senhasIguais = computed(() => senha.value === senhaConfirm.value)
// Ações
async function identificar() { async function identificar() {
if (!docValido.value) return if (!docValido.value) return
carregando.value = true carregando.value = true
erro.value = '' erro.value = ''
try { try {
const { data } = await primeiroAcessoService.verificarDocumento(docDigitos.value) const res = await primeiroAcessoService.verificarDocumento(docDigitos.value)
contribuinteNome.value = data.data.nome contribuinteNome.value = res.data.nome
canais.value = data.data.canais canais.value = res.data.canais
canalSelecionado.value = canais.value[0] ?? null canalSelecionado.value = canais.value[0] ?? null
etapa.value = 1 etapa.value = 1
} catch (e) { } catch (e) {
erro.value = e.response?.data?.description ?? 'Documento não encontrado ou não habilitado para este fluxo.' erro.value = e?.data?.description ?? 'Documento não encontrado ou não habilitado para este fluxo.'
} finally { } finally {
carregando.value = false carregando.value = false
} }
@ -60,7 +53,7 @@ async function enviarCodigo() {
await primeiroAcessoService.solicitarCodigo(docDigitos.value, canalSelecionado.value.tipo) await primeiroAcessoService.solicitarCodigo(docDigitos.value, canalSelecionado.value.tipo)
etapa.value = 2 etapa.value = 2
} catch (e) { } catch (e) {
erro.value = e.response?.data?.description ?? 'Não foi possível enviar o código. Tente novamente.' erro.value = e?.data?.description ?? 'Não foi possível enviar o código. Tente novamente.'
} finally { } finally {
carregando.value = false carregando.value = false
} }
@ -71,11 +64,11 @@ async function validarCodigo() {
carregando.value = true carregando.value = true
erro.value = '' erro.value = ''
try { try {
const { data } = await primeiroAcessoService.validarCodigo(docDigitos.value, codigo.value) const res = await primeiroAcessoService.validarCodigo(docDigitos.value, codigo.value)
tokenValidacao.value = data.data.token tokenValidacao.value = res.data.token
etapa.value = 3 etapa.value = 3
} catch (e) { } catch (e) {
erro.value = e.response?.data?.description ?? 'Código inválido ou expirado. Solicite um novo.' erro.value = e?.data?.description ?? 'Código inválido ou expirado. Solicite um novo.'
} finally { } finally {
carregando.value = false carregando.value = false
} }
@ -89,12 +82,23 @@ async function definirSenha() {
await primeiroAcessoService.definirSenha(tokenValidacao.value, senha.value) await primeiroAcessoService.definirSenha(tokenValidacao.value, senha.value)
etapa.value = 4 etapa.value = 4
} catch (e) { } catch (e) {
erro.value = e.response?.data?.description ?? 'Não foi possível definir a senha. Tente novamente.' erro.value = e?.data?.description ?? 'Não foi possível definir a senha. Tente novamente.'
} finally { } finally {
carregando.value = false carregando.value = false
} }
} }
async function entrarKeycloak() {
carregando.value = true
erro.value = ''
try {
await login(docDigitos.value, '/portal/painel')
} catch (e) {
carregando.value = false
erro.value = e?.data?.statusMessage ?? 'Não foi possível iniciar o login.'
}
}
const iconeCanal = { EMAIL: 'pi-envelope', SMS: 'pi-mobile', WHATSAPP: 'pi-whatsapp' } const iconeCanal = { EMAIL: 'pi-envelope', SMS: 'pi-mobile', WHATSAPP: 'pi-whatsapp' }
const labelCanal = { EMAIL: 'E-mail', SMS: 'SMS', WHATSAPP: 'WhatsApp' } const labelCanal = { EMAIL: 'E-mail', SMS: 'SMS', WHATSAPP: 'WhatsApp' }
</script> </script>
@ -103,7 +107,6 @@ const labelCanal = { EMAIL: 'E-mail', SMS: 'SMS', WHATSAPP: 'WhatsApp' }
<div class="min-h-[calc(100vh-8rem)] flex items-center justify-center px-4 py-12"> <div class="min-h-[calc(100vh-8rem)] flex items-center justify-center px-4 py-12">
<div class="w-full max-w-md"> <div class="w-full max-w-md">
<!-- Progresso -->
<div v-if="etapa < 4" class="flex items-center justify-center gap-2 mb-8"> <div v-if="etapa < 4" class="flex items-center justify-center gap-2 mb-8">
<div <div
v-for="i in 4" v-for="i in 4"
@ -118,7 +121,6 @@ const labelCanal = { EMAIL: 'E-mail', SMS: 'SMS', WHATSAPP: 'WhatsApp' }
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-lg border border-slate-200 dark:border-slate-700 overflow-hidden"> <div class="bg-white dark:bg-slate-800 rounded-2xl shadow-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
<!-- Cabeçalho -->
<div class="bg-gradient-to-r from-primary to-primary-700 px-8 py-6"> <div class="bg-gradient-to-r from-primary to-primary-700 px-8 py-6">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="w-10 h-10 bg-white/20 rounded-xl flex items-center justify-center" aria-hidden="true"> <div class="w-10 h-10 bg-white/20 rounded-xl flex items-center justify-center" aria-hidden="true">
@ -135,7 +137,6 @@ const labelCanal = { EMAIL: 'E-mail', SMS: 'SMS', WHATSAPP: 'WhatsApp' }
<div class="px-8 py-8 space-y-6"> <div class="px-8 py-8 space-y-6">
<!-- ETAPA 0 Identificação -->
<template v-if="etapa === 0"> <template v-if="etapa === 0">
<p class="text-sm text-slate-600 dark:text-slate-300"> <p class="text-sm text-slate-600 dark:text-slate-300">
Informe seu CPF ou CNPJ para verificarmos seu cadastro e iniciar a criação de senha. Informe seu CPF ou CNPJ para verificarmos seu cadastro e iniciar a criação de senha.
@ -150,7 +151,6 @@ const labelCanal = { EMAIL: 'E-mail', SMS: 'SMS', WHATSAPP: 'WhatsApp' }
<Button label="Continuar" icon="pi pi-arrow-right" icon-pos="right" class="w-full" size="large" :loading="carregando" :disabled="!docValido" @click="identificar" /> <Button label="Continuar" icon="pi pi-arrow-right" icon-pos="right" class="w-full" size="large" :loading="carregando" :disabled="!docValido" @click="identificar" />
</template> </template>
<!-- ETAPA 1 Canal -->
<template v-else-if="etapa === 1"> <template v-else-if="etapa === 1">
<div> <div>
<p class="text-sm text-slate-600 dark:text-slate-300 mb-1">Olá, <strong class="text-slate-800 dark:text-slate-100">{{ contribuinteNome }}</strong>.</p> <p class="text-sm text-slate-600 dark:text-slate-300 mb-1">Olá, <strong class="text-slate-800 dark:text-slate-100">{{ contribuinteNome }}</strong>.</p>
@ -187,7 +187,6 @@ const labelCanal = { EMAIL: 'E-mail', SMS: 'SMS', WHATSAPP: 'WhatsApp' }
</div> </div>
</template> </template>
<!-- ETAPA 2 Código -->
<template v-else-if="etapa === 2"> <template v-else-if="etapa === 2">
<p class="text-sm text-slate-600 dark:text-slate-300"> <p class="text-sm text-slate-600 dark:text-slate-300">
Enviamos um código de 6 dígitos para Enviamos um código de 6 dígitos para
@ -207,7 +206,6 @@ const labelCanal = { EMAIL: 'E-mail', SMS: 'SMS', WHATSAPP: 'WhatsApp' }
</button> </button>
</template> </template>
<!-- ETAPA 3 Senha -->
<template v-else-if="etapa === 3"> <template v-else-if="etapa === 3">
<p class="text-sm text-slate-600 dark:text-slate-300"> <p class="text-sm text-slate-600 dark:text-slate-300">
Crie uma senha segura com pelo menos 8 caracteres. Crie uma senha segura com pelo menos 8 caracteres.
@ -229,7 +227,6 @@ const labelCanal = { EMAIL: 'E-mail', SMS: 'SMS', WHATSAPP: 'WhatsApp' }
<Button label="Criar senha" icon="pi pi-check" class="w-full" size="large" :loading="carregando" :disabled="!senhaForte || !senhasIguais" @click="definirSenha" /> <Button label="Criar senha" icon="pi pi-check" class="w-full" size="large" :loading="carregando" :disabled="!senhaForte || !senhasIguais" @click="definirSenha" />
</template> </template>
<!-- ETAPA 4 Sucesso -->
<template v-else> <template v-else>
<div class="text-center py-4"> <div class="text-center py-4">
<div class="w-16 h-16 bg-emerald-100 dark:bg-emerald-900/30 rounded-2xl flex items-center justify-center mx-auto mb-4"> <div class="w-16 h-16 bg-emerald-100 dark:bg-emerald-900/30 rounded-2xl flex items-center justify-center mx-auto mb-4">
@ -240,17 +237,16 @@ const labelCanal = { EMAIL: 'E-mail', SMS: 'SMS', WHATSAPP: 'WhatsApp' }
Você pode acessar o portal com seu CPF/CNPJ e a nova senha. Você pode acessar o portal com seu CPF/CNPJ e a nova senha.
</p> </p>
</div> </div>
<Button label="Ir para o login" icon="pi pi-sign-in" class="w-full" size="large" @click="router.push({ name: 'login', query: { doc: docDigitos } })" /> <Button label="Ir para o login" icon="pi pi-sign-in" class="w-full" size="large" :loading="carregando" @click="entrarKeycloak" />
</template> </template>
</div> </div>
</div> </div>
<!-- Link voltar -->
<div v-if="etapa < 4" class="text-center mt-4"> <div v-if="etapa < 4" class="text-center mt-4">
<button <button
class="inline-flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 transition-colors py-3 px-4 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800" class="inline-flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 transition-colors py-3 px-4 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800"
@click="router.push({ name: 'home' })" @click="router.push('/')"
> >
<i class="pi pi-arrow-left text-xs" aria-hidden="true" /> <i class="pi pi-arrow-left text-xs" aria-hidden="true" />
Voltar à página inicial Voltar à página inicial

View File

@ -1,14 +1,14 @@
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useRouter } from 'vue-router' import { useAuth } from '@/composables/useAuth'
import { certidaoService } from '@/services/certidaoService' import { certidaoService } from '@/services/certidaoService'
import DocumentoInput from '@/components/auth/DocumentoInput.vue'
const router = useRouter() const router = useRouter()
const { isAuthenticated, documento: docUsuarioLogado, nomeUsuario } = useAuth()
const documento = ref('') const documento = ref(isAuthenticated.value ? docUsuarioLogado.value : '')
const tipoCertidao = ref('negativa') const tipoCertidao = ref('negativa')
const etapa = ref('formulario') // formulario | resultado | erro const etapa = ref('formulario')
const carregandoConsulta = ref(false) const carregandoConsulta = ref(false)
const carregandoEmissao = ref(false) const carregandoEmissao = ref(false)
const resultado = ref(null) const resultado = ref(null)
@ -30,11 +30,11 @@ async function consultar() {
carregandoConsulta.value = true carregandoConsulta.value = true
mensagemErro.value = '' mensagemErro.value = ''
try { try {
const { data } = await certidaoService.consultar(documento.value) const res = await certidaoService.consultar(documento.value)
resultado.value = data.data resultado.value = res.data
etapa.value = 'resultado' etapa.value = 'resultado'
} catch (e) { } catch (e) {
mensagemErro.value = e.response?.data?.description ?? 'Não foi possível consultar a situação fiscal. Tente novamente.' mensagemErro.value = e?.data?.description ?? 'Não foi possível consultar a situação fiscal. Tente novamente.'
} finally { } finally {
carregandoConsulta.value = false carregandoConsulta.value = false
} }
@ -43,8 +43,8 @@ async function consultar() {
async function emitir() { async function emitir() {
carregandoEmissao.value = true carregandoEmissao.value = true
try { try {
const { data } = await certidaoService.emitir(documento.value, tipoCertidao.value) const buf = await certidaoService.emitir(documento.value, tipoCertidao.value)
const url = URL.createObjectURL(new Blob([data], { type: 'application/pdf' })) const url = URL.createObjectURL(new Blob([buf], { type: 'application/pdf' }))
const a = document.createElement('a') const a = document.createElement('a')
a.href = url a.href = url
a.download = `certidao-${tipoCertidao.value}-${documento.value}.pdf` a.download = `certidao-${tipoCertidao.value}-${documento.value}.pdf`
@ -68,16 +68,14 @@ function reiniciar() {
<template> <template>
<div class="max-w-2xl mx-auto px-4 sm:px-6 py-12"> <div class="max-w-2xl mx-auto px-4 sm:px-6 py-12">
<!-- Voltar -->
<button <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" 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({ name: 'servicos' })" @click="router.push('/servicos')"
> >
<i class="pi pi-arrow-left text-xs" aria-hidden="true" /> <i class="pi pi-arrow-left text-xs" aria-hidden="true" />
Voltar aos serviços Voltar aos serviços
</button> </button>
<!-- Cabeçalho -->
<div class="flex items-center gap-4 mb-8"> <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"> <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" /> <i class="pi pi-file-check text-primary text-xl" aria-hidden="true" />
@ -88,14 +86,21 @@ function reiniciar() {
</div> </div>
</div> </div>
<!-- FORMULÁRIO -->
<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 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> <div>
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5"> <label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">
CPF ou CNPJ do contribuinte CPF ou CNPJ do contribuinte
</label> </label>
<DocumentoInput v-model="documento" @keyup.enter="consultar" /> <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>
<div> <div>
@ -134,10 +139,8 @@ function reiniciar() {
/> />
</div> </div>
<!-- RESULTADO -->
<div v-else-if="etapa === 'resultado'" class="space-y-4"> <div v-else-if="etapa === 'resultado'" class="space-y-4">
<!-- Status fiscal -->
<div <div
class="bg-white dark:bg-slate-800 rounded-2xl border p-6" class="bg-white dark:bg-slate-800 rounded-2xl border p-6"
:class="resultado?.situacao === 'NEGATIVA' :class="resultado?.situacao === 'NEGATIVA'
@ -169,7 +172,6 @@ function reiniciar() {
</div> </div>
</div> </div>
<!-- Tipo selecionado -->
<div class="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 p-6"> <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="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"> <p class="font-semibold text-slate-800 dark:text-slate-100">

View File

@ -1,12 +1,9 @@
<script setup> <script setup>
import { useRouter } from 'vue-router'
import ServiceCard from '@/components/common/ServiceCard.vue'
const router = useRouter() const router = useRouter()
const servicos = [ const servicos = [
{ icon: 'pi-file-check', titulo: 'Emissão de Certidão', descricao: 'Emita certidões de situação fiscal.', to: { name: 'certidao' } }, { icon: 'pi-file-check', titulo: 'Emissão de Certidão', descricao: 'Emita certidões de situação fiscal.', to: '/servicos/certidao' },
{ icon: 'pi-home', titulo: 'IPTU — Débitos e Carnê', descricao: 'Consulte débitos e imprima o carnê.', to: { name: 'iptu' } }, { icon: 'pi-home', titulo: 'IPTU — Débitos e Carnê', descricao: 'Consulte débitos e imprima o carnê.', to: '/servicos/iptu' },
{ icon: 'pi-verified', titulo: 'Validar Documento', descricao: 'Valide certidões e documentos emitidos.' }, { icon: 'pi-verified', titulo: 'Validar Documento', descricao: 'Valide certidões e documentos emitidos.' },
{ icon: 'pi-map-marker', titulo: 'Consulta Cadastral', descricao: 'Consulte dados cadastrais do imóvel ou empresa.' }, { icon: 'pi-map-marker', titulo: 'Consulta Cadastral', descricao: 'Consulte dados cadastrais do imóvel ou empresa.' },
{ icon: 'pi-building', titulo: 'Alvará — Consulta Pública', descricao: 'Situação de alvarás pelo número do processo.' }, { icon: 'pi-building', titulo: 'Alvará — Consulta Pública', descricao: 'Situação de alvarás pelo número do processo.' },
@ -16,10 +13,9 @@ const servicos = [
<template> <template>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<!-- Voltar -->
<button <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-6 py-1" 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-6 py-1"
@click="router.push({ name: 'home' })" @click="router.push('/')"
> >
<i class="pi pi-arrow-left text-xs" aria-hidden="true" /> <i class="pi pi-arrow-left text-xs" aria-hidden="true" />
Voltar Voltar

View File

@ -1,17 +1,17 @@
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue'
import { useRouter } from 'vue-router' import { useAuth } from '@/composables/useAuth'
import { iptuService } from '@/services/iptuService' import { iptuService } from '@/services/iptuService'
import DocumentoInput from '@/components/auth/DocumentoInput.vue'
const router = useRouter() const router = useRouter()
const { isAuthenticated, documento: docUsuarioLogado, nomeUsuario } = useAuth()
const modoConsulta = ref('documento') // 'documento' | 'inscricao' const modoConsulta = ref('documento')
const documento = ref('') const documento = ref(isAuthenticated.value ? docUsuarioLogado.value : '')
const inscricao = ref('') const inscricao = ref('')
const etapa = ref('formulario') // formulario | resultado const etapa = ref('formulario')
const carregando = ref(false) const carregando = ref(false)
const carregandoPdf = ref(null) // id do débito sendo gerado const carregandoPdf = ref(null)
const imoveis = ref([]) const imoveis = ref([])
const imovelSelecionado = ref(null) const imovelSelecionado = ref(null)
const mensagemErro = ref('') const mensagemErro = ref('')
@ -25,15 +25,15 @@ async function consultar() {
imovelSelecionado.value = null imovelSelecionado.value = null
try { try {
const { data } = modoConsulta.value === 'documento' const res = modoConsulta.value === 'documento'
? await iptuService.consultarPorDocumento(documento.value) ? await iptuService.consultarPorDocumento(documento.value)
: await iptuService.consultarPorInscricao(inscricao.value) : await iptuService.consultarPorInscricao(inscricao.value)
imoveis.value = data.data ?? [] imoveis.value = res.data ?? []
if (imoveis.value.length === 1) imovelSelecionado.value = imoveis.value[0] if (imoveis.value.length === 1) imovelSelecionado.value = imoveis.value[0]
etapa.value = 'resultado' etapa.value = 'resultado'
} catch (e) { } catch (e) {
mensagemErro.value = e.response?.data?.description ?? 'Não foi possível localizar os imóveis. Verifique os dados e tente novamente.' mensagemErro.value = e?.data?.description ?? 'Não foi possível localizar os imóveis. Verifique os dados e tente novamente.'
} finally { } finally {
carregando.value = false carregando.value = false
} }
@ -42,8 +42,8 @@ async function consultar() {
async function emitirCarne(imovel) { async function emitirCarne(imovel) {
carregandoPdf.value = `carne-${imovel.inscricaoImobiliaria}` carregandoPdf.value = `carne-${imovel.inscricaoImobiliaria}`
try { try {
const { data } = await iptuService.emitirCarne(imovel.inscricaoImobiliaria, exercicioAtual) const buf = await iptuService.emitirCarne(imovel.inscricaoImobiliaria, exercicioAtual)
const url = URL.createObjectURL(new Blob([data], { type: 'application/pdf' })) const url = URL.createObjectURL(new Blob([buf], { type: 'application/pdf' }))
const a = document.createElement('a') const a = document.createElement('a')
a.href = url a.href = url
a.download = `carne-iptu-${imovel.inscricaoImobiliaria}-${exercicioAtual}.pdf` a.download = `carne-iptu-${imovel.inscricaoImobiliaria}-${exercicioAtual}.pdf`
@ -59,8 +59,8 @@ async function emitirCarne(imovel) {
async function emitirBoleto(debito) { async function emitirBoleto(debito) {
carregandoPdf.value = `boleto-${debito.id}` carregandoPdf.value = `boleto-${debito.id}`
try { try {
const { data } = await iptuService.emitirBoleto(debito.id) const buf = await iptuService.emitirBoleto(debito.id)
const url = URL.createObjectURL(new Blob([data], { type: 'application/pdf' })) const url = URL.createObjectURL(new Blob([buf], { type: 'application/pdf' }))
const a = document.createElement('a') const a = document.createElement('a')
a.href = url a.href = url
a.download = `boleto-iptu-${debito.id}.pdf` a.download = `boleto-iptu-${debito.id}.pdf`
@ -90,16 +90,14 @@ function formatarMoeda(valor) {
<template> <template>
<div class="max-w-2xl mx-auto px-4 sm:px-6 py-12"> <div class="max-w-2xl mx-auto px-4 sm:px-6 py-12">
<!-- Voltar -->
<button <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" 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({ name: 'servicos' })" @click="router.push('/servicos')"
> >
<i class="pi pi-arrow-left text-xs" aria-hidden="true" /> <i class="pi pi-arrow-left text-xs" aria-hidden="true" />
Voltar aos serviços Voltar aos serviços
</button> </button>
<!-- Cabeçalho -->
<div class="flex items-center gap-4 mb-8"> <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"> <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-home text-primary text-xl" aria-hidden="true" /> <i class="pi pi-home text-primary text-xl" aria-hidden="true" />
@ -110,10 +108,8 @@ function formatarMoeda(valor) {
</div> </div>
</div> </div>
<!-- FORMULÁRIO -->
<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 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">
<!-- Selector de modo -->
<div> <div>
<p class="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-3">Buscar por</p> <p class="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-3">Buscar por</p>
<div class="flex gap-3"> <div class="flex gap-3">
@ -142,12 +138,16 @@ function formatarMoeda(valor) {
</div> </div>
</div> </div>
<!-- Campo dinâmico -->
<div> <div>
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5"> <label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">
{{ modoConsulta === 'documento' ? 'CPF ou CNPJ do proprietário' : 'Número da inscrição imobiliária' }} {{ modoConsulta === 'documento' ? 'CPF ou CNPJ do proprietário' : 'Número da inscrição imobiliária' }}
</label> </label>
<DocumentoInput v-if="modoConsulta === 'documento'" v-model="documento" @keyup.enter="consultar" /> <DocumentoInput
v-if="modoConsulta === 'documento'"
v-model="documento"
:disabled="isAuthenticated"
@keyup.enter="consultar"
/>
<InputText <InputText
v-else v-else
v-model="inscricao" v-model="inscricao"
@ -156,6 +156,10 @@ function formatarMoeda(valor) {
size="large" size="large"
@keyup.enter="consultar" @keyup.enter="consultar"
/> />
<p v-if="modoConsulta === 'documento' && 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 v-if="mensagemErro" role="alert" class="text-sm text-red-600 dark:text-red-400 flex items-center gap-1.5"> <p v-if="mensagemErro" role="alert" class="text-sm text-red-600 dark:text-red-400 flex items-center gap-1.5">
@ -174,10 +178,8 @@ function formatarMoeda(valor) {
/> />
</div> </div>
<!-- RESULTADO -->
<div v-else-if="etapa === 'resultado'" class="space-y-4"> <div v-else-if="etapa === 'resultado'" class="space-y-4">
<!-- Selector de imóvel se houver mais de um -->
<div v-if="imoveis.length > 1" class="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 p-6"> <div v-if="imoveis.length > 1" class="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 p-6">
<p class="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-3"> <p class="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-3">
{{ imoveis.length }} imóveis encontrados selecione um {{ imoveis.length }} imóveis encontrados selecione um
@ -198,7 +200,6 @@ function formatarMoeda(valor) {
</div> </div>
</div> </div>
<!-- Detalhe do imóvel selecionado -->
<template v-if="imovelSelecionado"> <template v-if="imovelSelecionado">
<div class="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 p-6"> <div class="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 p-6">
<div class="flex items-start justify-between gap-4 mb-4"> <div class="flex items-start justify-between gap-4 mb-4">
@ -216,7 +217,6 @@ function formatarMoeda(valor) {
/> />
</div> </div>
<!-- Tabela de débitos -->
<div v-if="imovelSelecionado.debitos?.length" class="mt-4"> <div v-if="imovelSelecionado.debitos?.length" class="mt-4">
<p class="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-3">Débitos em aberto</p> <p class="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-3">Débitos em aberto</p>
<div class="space-y-2"> <div class="space-y-2">

View File

@ -0,0 +1,25 @@
import { useAuthStore } from '@/stores/authStore'
interface MeResponse {
name: string
documento: string
email: string
roles: string[]
}
/**
* Hidrata o authStore no SSR a partir do cookie de sessão.
* Se o usuário está logado, o HTML inicial reflete o estado autenticado.
*/
export default defineNuxtPlugin(async () => {
const store = useAuthStore()
try {
const me = await $fetch<MeResponse>('/api/auth/me', {
headers: useRequestHeaders(['cookie']),
})
store.setUser(me)
} catch {
// 401 esperado quando não há sessão — segue como anônimo
}
})

View File

@ -0,0 +1,50 @@
import { tenantFromHost } from '@/utils/tenant'
import { usePrefeituraStore } from '@/stores/prefeituraStore'
interface PrefeituraInfo {
codigoMunicipio: number
nomePrefeitura: string
dominio: string
template: string
pathLogo?: string
pathBackground?: string
}
interface ApiEnvelope<T> {
data: T
}
function resolverUrl(path: string | undefined, base: string): string | null {
if (!path) return null
if (path.startsWith('http')) return path
return `${base}${path}`
}
export default defineNuxtPlugin(async (nuxtApp) => {
const event = useRequestEvent(nuxtApp)
const host = event?.node.req.headers.host
const dominio = tenantFromHost(host)
const cfg = useRuntimeConfig()
const store = usePrefeituraStore()
try {
const res = await $fetch<ApiEnvelope<PrefeituraInfo>>(
`${cfg.coreApiUrl}/api/v1/publico/prefeitura/${dominio}`,
{ timeout: 8000 },
)
const info = res?.data
if (!info?.codigoMunicipio) return
store.$patch({
codigoMunicipio: info.codigoMunicipio,
nomePrefeitura: info.nomePrefeitura,
dominio: info.dominio,
template: info.template,
pathLogo: resolverUrl(info.pathLogo, cfg.coreApiUrl),
pathBackground: resolverUrl(info.pathBackground, cfg.coreApiUrl),
})
} catch (err) {
console.warn(`[prefeitura.server] bootstrap falhou para '${dominio}':`, (err as Error).message)
}
})

View File

@ -1,106 +0,0 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/authStore'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
scrollBehavior: () => ({ top: 0 }),
routes: [
{
path: '/',
component: () => import('@/layouts/PublicLayout.vue'),
children: [
{
path: '',
name: 'home',
component: () => import('@/views/public/HomeView.vue'),
},
{
path: 'login',
name: 'login',
component: () => import('@/views/public/LoginView.vue'),
},
{
path: 'primeiro-acesso',
name: 'primeiro-acesso',
component: () => import('@/views/public/PrimeiroAcessoView.vue'),
},
{
path: 'credenciamento',
name: 'credenciamento',
component: () => import('@/views/public/CredenciamentoView.vue'),
},
{
path: 'servicos',
name: 'servicos',
component: () => import('@/views/servicos/ServicosHubView.vue'),
},
{
path: 'servicos/certidao',
name: 'certidao',
component: () => import('@/views/servicos/CertidaoView.vue'),
},
{
path: 'servicos/iptu',
name: 'iptu',
component: () => import('@/views/servicos/IptuView.vue'),
},
],
},
{
path: '/portal',
component: () => import('@/layouts/PortalLayout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
redirect: { name: 'painel' },
},
{
path: 'painel',
name: 'painel',
component: () => import('@/views/portal/PainelView.vue'),
},
{
path: 'debitos',
name: 'debitos',
component: () => import('@/views/portal/DebitosView.vue'),
},
{
path: 'certidoes',
name: 'certidoes-portal',
component: () => import('@/views/portal/CertidoesView.vue'),
},
{
path: 'alvaras',
name: 'alvaras',
component: () => import('@/views/portal/AlvarasView.vue'),
},
{
path: 'pagamentos',
name: 'pagamentos',
component: () => import('@/views/portal/PagamentosView.vue'),
},
{
path: 'dados',
name: 'dados',
component: () => import('@/views/portal/DadosView.vue'),
},
],
},
{
path: '/:pathMatch(.*)*',
redirect: { name: 'home' },
},
],
})
router.beforeEach((to) => {
if (!to.meta.requiresAuth) return true
const auth = useAuthStore()
if (!auth.isAuthenticated) {
return { name: 'home' }
}
})
export default router

View File

@ -1,34 +0,0 @@
/**
* Serviço de autenticação Keycloak PKCE
*
* Fluxo:
* 1. Home coleta o documento (CPF/CNPJ)
* 2. Redireciona para /login?doc=XXX
* 3. LoginView exibe o documento e solicita a senha
* 4. Ao submeter, este serviço inicia o PKCE e redireciona ao Keycloak
* 5. Keycloak devolve ao callback com o code
* 6. handleCallback troca o code pelo token e salva na authStore
*/
const KEYCLOAK_BASE = import.meta.env.VITE_KEYCLOAK_URL ?? 'https://sso.modumfiscal.com.br/realms/modumfiscal-dev'
const CLIENT_ID = import.meta.env.VITE_KEYCLOAK_CLIENT_ID ?? 'portal-modumfiscal'
const REDIRECT_URI = `${window.location.origin}/callback`
export const authService = {
async iniciarLogin(documento) {
// Implementar PKCE com pkce-challenge quando integrar com Keycloak
// Por ora redireciona para /login com o documento
return { documento }
},
async handleCallback(code) {
// Trocar code por token via Keycloak
// A ser implementado na fase de integração
throw new Error('Keycloak callback não implementado nesta fase')
},
logout() {
const logoutUrl = `${KEYCLOAK_BASE}/protocol/openid-connect/logout?redirect_uri=${encodeURIComponent(window.location.origin)}`
window.location.href = logoutUrl
},
}

View File

@ -1,16 +1,22 @@
import { apiClientPublico } from '@/config/apiClient' const FETCH_HEADERS = { 'X-Requested-With': 'fetch' }
function proxyUrl(path) {
return `/api/proxy${path}`
}
export const certidaoService = { export const certidaoService = {
consultar(documento) { consultar(documento) {
return apiClientPublico.get('/publico/certidao/consultar', { return $fetch(proxyUrl('/publico/certidao/consultar'), {
params: { documento }, headers: FETCH_HEADERS,
query: { documento },
}) })
}, },
emitir(documento, tipoCertidao) { emitir(documento, tipoCertidao) {
return apiClientPublico.get('/publico/certidao/emitir', { return $fetch(proxyUrl('/publico/certidao/emitir'), {
params: { documento, tipoCertidao }, headers: FETCH_HEADERS,
responseType: 'blob', query: { documento, tipoCertidao },
responseType: 'arrayBuffer',
}) })
}, },
} }

View File

@ -1,17 +1,26 @@
import { apiClientPublico } from '@/config/apiClient' const FETCH_HEADERS = { 'X-Requested-With': 'fetch' }
function proxyUrl(path) {
return `/api/proxy${path}`
}
export const credenciamentoService = { export const credenciamentoService = {
verificarDocumento(documento) { verificarDocumento(documento) {
return apiClientPublico.get('/publico/credenciamento/verificar', { return $fetch(proxyUrl('/publico/credenciamento/verificar'), {
params: { documento }, headers: FETCH_HEADERS,
query: { documento },
}) })
}, },
buscarCep(cep) { buscarCep(cep) {
return apiClientPublico.get(`/publico/cep/${cep}`) return $fetch(proxyUrl(`/publico/cep/${cep}`), { headers: FETCH_HEADERS })
}, },
solicitar(payload) { solicitar(payload) {
return apiClientPublico.post('/publico/credenciamento/solicitar', payload) return $fetch(proxyUrl('/publico/credenciamento/solicitar'), {
method: 'POST',
headers: FETCH_HEADERS,
body: payload,
})
}, },
} }

View File

@ -1,28 +1,36 @@
import { apiClientPublico } from '@/config/apiClient' const FETCH_HEADERS = { 'X-Requested-With': 'fetch' }
function proxyUrl(path) {
return `/api/proxy${path}`
}
export const iptuService = { export const iptuService = {
consultarPorDocumento(documento) { consultarPorDocumento(documento) {
return apiClientPublico.get('/publico/iptu/consultar', { return $fetch(proxyUrl('/publico/iptu/consultar'), {
params: { documento }, headers: FETCH_HEADERS,
query: { documento },
}) })
}, },
consultarPorInscricao(inscricao) { consultarPorInscricao(inscricao) {
return apiClientPublico.get('/publico/iptu/consultar', { return $fetch(proxyUrl('/publico/iptu/consultar'), {
params: { inscricaoImobiliaria: inscricao }, headers: FETCH_HEADERS,
query: { inscricaoImobiliaria: inscricao },
}) })
}, },
emitirCarne(inscricao, exercicio) { emitirCarne(inscricao, exercicio) {
return apiClientPublico.get('/publico/iptu/carne', { return $fetch(proxyUrl('/publico/iptu/carne'), {
params: { inscricaoImobiliaria: inscricao, exercicio }, headers: FETCH_HEADERS,
responseType: 'blob', query: { inscricaoImobiliaria: inscricao, exercicio },
responseType: 'arrayBuffer',
}) })
}, },
emitirBoleto(idDebito) { emitirBoleto(idDebito) {
return apiClientPublico.get(`/publico/iptu/boleto/${idDebito}`, { return $fetch(proxyUrl(`/publico/iptu/boleto/${idDebito}`), {
responseType: 'blob', headers: FETCH_HEADERS,
responseType: 'arrayBuffer',
}) })
}, },
} }

View File

@ -1,61 +1,86 @@
import apiClient from '@/config/apiClient' // Todas as chamadas vão via /api/proxy/** (BFF injeta Bearer + tenant headers).
// Cada método retorna o envelope cru do core-api: { data, message, statusCode, ... }
// — exceto rotas binárias (PDF), que retornam ArrayBuffer.
const FETCH_HEADERS = { 'X-Requested-With': 'fetch' }
function proxyUrl(path) {
return `/api/proxy${path}`
}
export const portalService = { export const portalService = {
// Painel // ─── Painel ──────────────────────────────────────────────────────────────
getPainelResumo() { getPainelResumo() {
return apiClient.get('/contribuinte/painel/resumo') return $fetch(proxyUrl('/contribuinte/painel/resumo'), { headers: FETCH_HEADERS })
}, },
getAtividades(pagina = 0, tamanho = 5) { getAtividades(pagina = 0, tamanho = 5) {
return apiClient.get('/contribuinte/painel/atividades', { return $fetch(proxyUrl('/contribuinte/painel/atividades'), {
params: { pagina, tamanho }, headers: FETCH_HEADERS,
query: { pagina, tamanho },
}) })
}, },
// Débitos // ─── Débitos ─────────────────────────────────────────────────────────────
getDebitos(params = {}) { getDebitos(params = {}) {
return apiClient.get('/contribuinte/debitos', { params }) return $fetch(proxyUrl('/contribuinte/debitos'), {
headers: FETCH_HEADERS,
query: params,
})
}, },
emitirGuia(idDebito) { emitirGuia(idDebito) {
return apiClient.get(`/contribuinte/debitos/${idDebito}/guia`, { return $fetch(proxyUrl(`/contribuinte/debitos/${idDebito}/guia`), {
responseType: 'blob', headers: FETCH_HEADERS,
responseType: 'arrayBuffer',
}) })
}, },
// Certidões // ─── Certidões ───────────────────────────────────────────────────────────
getCertidoes() { getCertidoes() {
return apiClient.get('/contribuinte/certidoes') return $fetch(proxyUrl('/contribuinte/certidoes'), { headers: FETCH_HEADERS })
}, },
reemitirCertidao(idCertidao) { reemitirCertidao(idCertidao) {
return apiClient.get(`/contribuinte/certidoes/${idCertidao}/pdf`, { return $fetch(proxyUrl(`/contribuinte/certidoes/${idCertidao}/pdf`), {
responseType: 'blob', headers: FETCH_HEADERS,
responseType: 'arrayBuffer',
}) })
}, },
// Alvarás // ─── Alvarás ─────────────────────────────────────────────────────────────
getAlvaras(params = {}) { getAlvaras(params = {}) {
return apiClient.get('/contribuinte/alvaras', { params }) return $fetch(proxyUrl('/contribuinte/alvaras'), {
headers: FETCH_HEADERS,
query: params,
})
}, },
// Pagamentos // ─── Pagamentos ──────────────────────────────────────────────────────────
getPagamentos(params = {}) { getPagamentos(params = {}) {
return apiClient.get('/contribuinte/pagamentos', { params }) return $fetch(proxyUrl('/contribuinte/pagamentos'), {
headers: FETCH_HEADERS,
query: params,
})
}, },
getComprovante(idPagamento) { getComprovante(idPagamento) {
return apiClient.get(`/contribuinte/pagamentos/${idPagamento}/comprovante`, { return $fetch(proxyUrl(`/contribuinte/pagamentos/${idPagamento}/comprovante`), {
responseType: 'blob', headers: FETCH_HEADERS,
responseType: 'arrayBuffer',
}) })
}, },
// Dados cadastrais // ─── Dados cadastrais ────────────────────────────────────────────────────
getDadosCadastrais() { getDadosCadastrais() {
return apiClient.get('/contribuinte/dados') return $fetch(proxyUrl('/contribuinte/dados'), { headers: FETCH_HEADERS })
}, },
atualizarContato(payload) { atualizarContato(payload) {
return apiClient.put('/contribuinte/dados/contato', payload) return $fetch(proxyUrl('/contribuinte/dados/contato'), {
method: 'PUT',
headers: FETCH_HEADERS,
body: payload,
})
}, },
} }

View File

@ -1,7 +0,0 @@
import { apiClientPublico } from '@/config/apiClient'
export const prefeituraService = {
getPrefeituraInfo(dominio) {
return apiClientPublico.get(`/publico/prefeitura/${dominio}`)
},
}

View File

@ -1,21 +1,38 @@
import { apiClientPublico } from '@/config/apiClient' const FETCH_HEADERS = { 'X-Requested-With': 'fetch' }
function proxyUrl(path) {
return `/api/proxy${path}`
}
export const primeiroAcessoService = { export const primeiroAcessoService = {
verificarDocumento(documento) { verificarDocumento(documento) {
return apiClientPublico.get('/publico/primeiro-acesso/verificar', { return $fetch(proxyUrl('/publico/primeiro-acesso/verificar'), {
params: { documento }, headers: FETCH_HEADERS,
query: { documento },
}) })
}, },
solicitarCodigo(documento, canal) { solicitarCodigo(documento, canal) {
return apiClientPublico.post('/publico/primeiro-acesso/codigo', { documento, canal }) return $fetch(proxyUrl('/publico/primeiro-acesso/codigo'), {
method: 'POST',
headers: FETCH_HEADERS,
body: { documento, canal },
})
}, },
validarCodigo(documento, codigo) { validarCodigo(documento, codigo) {
return apiClientPublico.post('/publico/primeiro-acesso/validar', { documento, codigo }) return $fetch(proxyUrl('/publico/primeiro-acesso/validar'), {
method: 'POST',
headers: FETCH_HEADERS,
body: { documento, codigo },
})
}, },
definirSenha(token, senha) { definirSenha(token, senha) {
return apiClientPublico.post('/publico/primeiro-acesso/senha', { token, senha }) return $fetch(proxyUrl('/publico/primeiro-acesso/senha'), {
method: 'POST',
headers: FETCH_HEADERS,
body: { token, senha },
})
}, },
} }

View File

@ -1,31 +1,32 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
export const useAuthStore = defineStore( // Token NÃO vive aqui — fica server-side em Redis, exposto ao front
'auth', // apenas como cookie httpOnly opaco. Este store guarda só metadados do usuário,
() => { // hidratados via /api/auth/me em plugins/auth.server.ts (SSR) e useAuth().fetchMe().
const token = ref(null) export const useAuthStore = defineStore('auth', () => {
const userInfo = ref(null) const user = ref(null)
const isAuthenticated = computed(() => !!token.value) const isAuthenticated = computed(() => !!user.value)
const nomeUsuario = computed(() => userInfo.value?.name ?? '') const nomeUsuario = computed(() => user.value?.name ?? '')
const documento = computed(() => userInfo.value?.preferred_username ?? '') const documento = computed(() => user.value?.documento ?? '')
const roles = computed(() => user.value?.roles ?? [])
function setSession(accessToken, info) { function setUser(info) {
token.value = accessToken user.value = info
userInfo.value = info
} }
function clearSession() { function clearUser() {
token.value = null user.value = null
userInfo.value = null
} }
return { token, userInfo, isAuthenticated, nomeUsuario, documento, setSession, clearSession } return {
}, user,
{ isAuthenticated,
persist: { nomeUsuario,
paths: ['token', 'userInfo'], documento,
}, roles,
}, setUser,
) clearUser,
}
})

View File

@ -1,5 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
// Store hidratado server-side via plugins/prefeitura.server.ts.
// Sem persist — a cada SSR o bootstrap re-popula a partir do core-api.
export const usePrefeituraStore = defineStore('prefeitura', { export const usePrefeituraStore = defineStore('prefeitura', {
state: () => ({ state: () => ({
codigoMunicipio: null, codigoMunicipio: null,
@ -7,7 +9,6 @@ export const usePrefeituraStore = defineStore('prefeitura', {
dominio: null, dominio: null,
template: null, template: null,
pathLogo: null, pathLogo: null,
pathBackground: null, // URL de foto de fundo do município (quando a API suportar) pathBackground: null,
}), }),
persist: true,
}) })

View File

@ -1,26 +0,0 @@
export function getTenant() {
const tenant = window.location.hostname.split('.')[0]
if (isValidTenant(tenant)) return tenant
const stored = localStorage.getItem('current_dominio')
if (stored && isValidTenant(stored)) return stored
return 'sistema'
}
export function setTenant(tenant) {
if (isValidTenant(tenant)) {
localStorage.setItem('current_dominio', tenant)
}
}
export function clearTenant() {
localStorage.removeItem('current_dominio')
}
function isValidTenant(tenant) {
if (!tenant || typeof tenant !== 'string') return false
const invalidos = ['0.0.0.0', 'www', 'api', 'admin', 'test', 'dev', 'development', 'staging', 'production', 'localhost']
return !invalidos.includes(tenant.toLowerCase()) && tenant.length > 1 && /^[a-zA-Z0-9-]+$/.test(tenant)
}

32
src/utils/tenant.ts Normal file
View File

@ -0,0 +1,32 @@
const INVALID_SUBDOMAINS = new Set([
'0.0.0.0',
'www',
'api',
'admin',
'test',
'dev',
'development',
'staging',
'production',
'localhost',
])
const DEFAULT_TENANT = 'sistema'
export function tenantFromHost(host: string | undefined | null): string {
if (!host) return DEFAULT_TENANT
const sub = host.split(':')[0]?.split('.')[0]?.toLowerCase()
if (!sub || sub.length <= 1) return DEFAULT_TENANT
if (INVALID_SUBDOMAINS.has(sub)) return DEFAULT_TENANT
if (!/^[a-z0-9-]+$/.test(sub)) return DEFAULT_TENANT
return sub
}
/**
* Resolve o tenant atual isomorfo (server e client).
* No server, prefira passar o host via `tenantFromHost(useRequestHeaders(['host']).host)`.
*/
export function getClientTenant(): string {
if (typeof window === 'undefined') return DEFAULT_TENANT
return tenantFromHost(window.location.host)
}

View File

@ -1,172 +0,0 @@
<script setup>
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
const senha = ref('')
const carregando = ref(false)
const erro = ref('')
const senhaId = 'campo-senha'
const erroId = 'erro-senha'
const docBruto = computed(() => (route.query.doc ?? '').replace(/\D/g, ''))
const docFormatado = computed(() => {
const d = docBruto.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 tipoDoc = computed(() => docBruto.value.length > 11 ? 'CNPJ' : 'CPF')
function trocarDocumento() {
router.push({ name: 'home' })
}
async function entrar() {
if (!senha.value) {
erro.value = 'Informe a senha para continuar.'
return
}
erro.value = ''
carregando.value = true
// Integração com Keycloak PKCE a ser implementado
setTimeout(() => {
carregando.value = false
erro.value = 'Integração com Keycloak ainda não configurada nesta fase.'
}, 800)
}
</script>
<template>
<div class="min-h-[calc(100vh-8rem)] flex items-center justify-center px-4 py-12">
<div class="w-full max-w-md">
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
<!-- Cabeçalho h1 para hierarquia de heading correta -->
<div class="bg-gradient-to-r from-primary-700 to-primary-800 px-8 py-6 bg-primary">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-white/20 rounded-xl flex items-center justify-center" aria-hidden="true">
<i class="pi pi-lock text-white text-lg" />
</div>
<div>
<h1 class="text-white font-bold text-base leading-tight">Acesso seguro</h1>
<p class="text-white/80 text-xs mt-0.5">Portal do Contribuinte</p>
</div>
</div>
</div>
<div class="px-8 py-8 space-y-6">
<!-- Documento identificado -->
<div>
<p class="text-xs font-semibold text-slate-600 dark:text-slate-400 uppercase tracking-wide mb-2">
Entrando como
</p>
<div class="flex items-center justify-between bg-slate-50 dark:bg-slate-700 border border-slate-200 dark:border-slate-600 rounded-xl px-4 py-3">
<div class="flex items-center gap-3">
<div class="w-8 h-8 bg-primary/10 rounded-lg flex items-center justify-center" aria-hidden="true">
<i class="pi pi-id-card text-primary text-sm" />
</div>
<div>
<p class="font-mono font-semibold text-slate-800 dark:text-slate-100 text-sm">{{ docFormatado }}</p>
<p class="text-xs text-slate-600 dark:text-slate-300">{{ tipoDoc }}</p>
</div>
</div>
<!-- Alvo de 44px via py-3 px-3 -->
<button
class="inline-flex items-center gap-1.5 text-sm text-primary hover:text-primary font-semibold px-3 py-3 rounded-lg hover:bg-primary/8 transition-colors"
aria-label="Trocar documento de identificação"
@click="trocarDocumento"
>
<i class="pi pi-pencil text-xs" aria-hidden="true" />
Trocar
</button>
</div>
</div>
<!-- Senha -->
<div>
<label :for="senhaId" class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">
Senha
</label>
<Password
v-model="senha"
:input-id="senhaId"
:feedback="false"
toggle-mask
placeholder="Digite sua senha"
class="w-full"
input-class="w-full"
size="large"
:invalid="!!erro"
:input-props="{ 'aria-describedby': erro ? erroId : undefined, 'aria-invalid': !!erro || undefined }"
@keyup.enter="entrar"
/>
<!-- aria-live="assertive": anuncia o erro imediatamente para leitores de tela -->
<p
:id="erroId"
role="alert"
aria-live="assertive"
class="mt-2 text-sm text-red-700 font-medium flex items-center gap-1.5 min-h-[1.25rem]"
>
<template v-if="erro">
<i class="pi pi-exclamation-circle text-sm" aria-hidden="true" />
{{ erro }}
</template>
</p>
</div>
<Button
label="Entrar"
icon="pi pi-sign-in"
class="w-full"
size="large"
:loading="carregando"
@click="entrar"
/>
<div class="text-center space-y-1">
<RouterLink
:to="{ name: 'primeiro-acesso' }"
class="block text-sm text-primary font-medium py-2 hover:underline"
>
Esqueci minha senha
</RouterLink>
<RouterLink
:to="{ name: 'credenciamento' }"
class="block text-sm text-slate-600 dark:text-slate-400 py-2 hover:text-slate-800 dark:hover:text-slate-200 transition-colors"
>
Ainda não tem acesso? <span class="text-primary font-semibold">Credenciar-se</span>
</RouterLink>
</div>
</div>
</div>
<!-- Voltar alvo de 44px via py-3 -->
<div class="text-center mt-4">
<button
class="inline-flex items-center gap-2 text-sm text-slate-600 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200 transition-colors py-3 px-4 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800"
@click="router.push({ name: 'home' })"
>
<i class="pi pi-arrow-left text-xs" aria-hidden="true" />
Voltar à página inicial
</button>
</div>
</div>
</div>
</template>

3
tsconfig.json Normal file
View File

@ -0,0 +1,3 @@
{
"extends": "./.nuxt/tsconfig.json"
}

View File

@ -1,21 +0,0 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
import Components from 'unplugin-vue-components/vite'
import { PrimeVueResolver } from '@primevue/auto-import-resolver'
export default defineConfig({
plugins: [
vue(),
tailwindcss(),
Components({
resolvers: [PrimeVueResolver()],
}),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
})