+
+ {{ isCnpj ? 'CNPJ' : 'CPF' }}
+
+
{{ isCnpj ? 'CNPJ' : 'CPF' }}
diff --git a/src/components/auth/LoginModal.vue b/src/components/auth/LoginModal.vue
new file mode 100644
index 0000000..b281602
--- /dev/null
+++ b/src/components/auth/LoginModal.vue
@@ -0,0 +1,110 @@
+
+
+
+
+
diff --git a/src/components/common/AccessibilityWidget.vue b/src/components/common/AccessibilityWidget.vue
index a67a160..e673b71 100644
--- a/src/components/common/AccessibilityWidget.vue
+++ b/src/components/common/AccessibilityWidget.vue
@@ -2,9 +2,9 @@
import { ref, watch, onMounted } from 'vue'
const aberto = ref(false)
-const nivelFonte = ref(Number(localStorage.getItem('a11y-fonte') || 0))
-const altoContraste = ref(localStorage.getItem('a11y-contraste') === '1')
-const modoEscuro = ref(localStorage.getItem('a11y-escuro') === '1')
+const nivelFonte = ref(0)
+const altoContraste = ref(false)
+const modoEscuro = ref(false)
const opcoesFonte = [
{ nivel: 0, label: 'A', title: 'Texto normal' },
@@ -18,23 +18,32 @@ function applyFonte(nivel) {
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(() => {
+ 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)
document.documentElement.classList.toggle('a11y-contrast', altoContraste.value)
document.documentElement.classList.toggle('app-dark', modoEscuro.value)
})
watch(nivelFonte, (val) => {
+ if (!import.meta.client) return
applyFonte(val)
localStorage.setItem('a11y-fonte', val)
})
watch(altoContraste, (val) => {
+ if (!import.meta.client) return
document.documentElement.classList.toggle('a11y-contrast', val)
localStorage.setItem('a11y-contraste', val ? '1' : '0')
})
watch(modoEscuro, (val) => {
+ if (!import.meta.client) return
document.documentElement.classList.toggle('app-dark', val)
localStorage.setItem('a11y-escuro', val ? '1' : '0')
})
diff --git a/src/components/common/AppHeader.vue b/src/components/common/AppHeader.vue
index 619cd31..7967c01 100644
--- a/src/components/common/AppHeader.vue
+++ b/src/components/common/AppHeader.vue
@@ -1,21 +1,32 @@
-
@@ -31,28 +42,32 @@ const logoSrc = computed(() => prefeitura.pathLogo || logoFallback)
{{ prefeitura.nomePrefeitura || 'ModumFiscal' }}
-
+
-
-
+
+
-
+
-
-
-
+
diff --git a/src/components/common/ServiceCard.vue b/src/components/common/ServiceCard.vue
index 3f7ea8f..5e271e5 100644
--- a/src/components/common/ServiceCard.vue
+++ b/src/components/common/ServiceCard.vue
@@ -6,14 +6,12 @@ defineProps({
to: { type: [String, Object], default: null },
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'
-
+
@@ -28,5 +26,18 @@ defineProps({
Acessar
-
+
+
+
+
+
+
{{ titulo }}
+
{{ descricao }}
+
+
diff --git a/src/composables/useApi.ts b/src/composables/useApi.ts
new file mode 100644
index 0000000..21d67b5
--- /dev/null
+++ b/src/composables/useApi.ts
@@ -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
('portal/contribuinte/debitos')
+ * const novo = await api.post('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(path: string, options: FetchOptions = {}): Promise {
+ return await $fetch(buildUrl(path), {
+ ...options,
+ headers: {
+ 'X-Requested-With': 'fetch',
+ ...(options.headers ?? {}),
+ },
+ })
+ }
+
+ return {
+ request,
+ get: (path: string, opts?: FetchOptions) =>
+ request(path, { ...opts, method: 'GET' }),
+ post: (path: string, body?: unknown, opts?: FetchOptions) =>
+ request(path, { ...opts, method: 'POST', body }),
+ put: (path: string, body?: unknown, opts?: FetchOptions) =>
+ request(path, { ...opts, method: 'PUT', body }),
+ patch: (path: string, body?: unknown, opts?: FetchOptions) =>
+ request(path, { ...opts, method: 'PATCH', body }),
+ delete: (path: string, opts?: FetchOptions) =>
+ request(path, { ...opts, method: 'DELETE' }),
+ }
+}
diff --git a/src/composables/useAuth.ts b/src/composables/useAuth.ts
new file mode 100644
index 0000000..659d803
--- /dev/null
+++ b/src/composables/useAuth.ts
@@ -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 {
+ try {
+ const me = await $fetch('/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,
+ }
+}
diff --git a/src/composables/useFocusLoginInput.ts b/src/composables/useFocusLoginInput.ts
new file mode 100644
index 0000000..c63f1da
--- /dev/null
+++ b/src/composables/useFocusLoginInput.ts
@@ -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 dá focus no documento.
+ *
+ * Se a home já está montada, o watch reage imediatamente.
+ */
+export function useFocusLoginInput() {
+ const requested = useState('focusLoginInput', () => false)
+
+ function request() {
+ requested.value = true
+ }
+
+ function consume() {
+ requested.value = false
+ }
+
+ return { requested, request, consume }
+}
diff --git a/src/composables/useLoginModal.ts b/src/composables/useLoginModal.ts
new file mode 100644
index 0000000..20fdd2b
--- /dev/null
+++ b/src/composables/useLoginModal.ts
@@ -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('loginModal:isOpen', () => false)
+ const returnTo = useState('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 }
+}
diff --git a/src/config/apiClient.js b/src/config/apiClient.js
deleted file mode 100644
index 568df4b..0000000
--- a/src/config/apiClient.js
+++ /dev/null
@@ -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
diff --git a/src/layouts/PublicLayout.vue b/src/layouts/default.vue
similarity index 62%
rename from src/layouts/PublicLayout.vue
rename to src/layouts/default.vue
index 4e5f6df..2bff13b 100644
--- a/src/layouts/PublicLayout.vue
+++ b/src/layouts/default.vue
@@ -7,11 +7,7 @@
-
-
-
-
-
+
diff --git a/src/layouts/PortalLayout.vue b/src/layouts/portal.vue
similarity index 66%
rename from src/layouts/PortalLayout.vue
rename to src/layouts/portal.vue
index 730f379..b9cea00 100644
--- a/src/layouts/PortalLayout.vue
+++ b/src/layouts/portal.vue
@@ -1,21 +1,20 @@
@@ -23,8 +22,8 @@ function sair() {