GET /internal/fiscal/clients returns every CLIENTE (active and inactive)
as {external_client_id, code, display_name, active} — CLIENTE.ID is the
identity (CODIGO is editable, same reasoning as FUNCIONARIO_PAINEL.ID in
SSO). Authenticated by a dedicated bearer token (FISCAL_INTERNAL_API_TOKEN,
never reused from SESSION_SECRET/SSO key/admin password), rate-limited per
IP, returns 503 rather than opening anonymously when the token isn't
configured. GTI Gerenciador stays the single source of truth: the Fiscal
Hub never receives MySQL credentials.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
/sso/fiscal now requires the state that GTI Fiscal creates when the flow
starts and signs it as the "nonce" claim; GTI Fiscal checks it against
its own one-time cookie. Without a valid state the panel sends the
browser to GTI Fiscal's /login, so the flow always starts there. This
prevents someone from pushing an assertion for their own account into
another person's browser (login CSRF).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sidebar "Fiscal" entry now goes to /sso/fiscal. For a logged-in,
active employee the panel issues a JWT signed with Ed25519 (the private
key exists only here; GTI Fiscal only holds the public key): iss/aud
fixed, sub = funcionario:<ID> (LOGIN is editable), 60 s lifetime, unique
jti that GTI Fiscal records to reject replays. No password, hash or panel
cookie leaves the panel.
The assertion is delivered in an auto-submitted POST, never in the URL,
so it stays out of proxy logs, browser history and Referer. That page
carries its own CSP allowing form-action only to the configured callback
origin; the security-headers middleware now keeps a route-defined CSP.
Without a session the employee goes through the normal login and comes
back; plain HTTP is redirected to the public HTTPS address; `next` only
accepts a relative path; the .env bootstrap account (no stable ID) is
refused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Opens the GTI Fiscal Hub (https://gtifiscal.gtigrupo.com.br) in a new
tab. Link only: GTI Fiscal keeps its own login, the panel's
authentication is unchanged. Already deployed to production.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mesmo padrão já usado pro CODIGO (db_clientes.codigo_em_uso): nova
db_clientes.cnpj_em_uso(documento, ignorar_id=None) checada em
criar()/editar() antes de gravar. A comparação ignora pontuação dos dois
lados (não só normaliza o valor recebido) porque CLIENTE.CNPJ tem
formatação inconsistente em registros legados (alguns com máscara,
outros sem) — sem isso, um cadastro novo com o mesmo documento de um
legado ainda mascarado passaria batido.
app/templates/cliente_form.html: nova mensagem de erro
'erro_cnpj_duplicado', mesmo estilo visual do erro de código duplicado.
6 testes novos (unitário do db + integração do router pros dois
fluxos), suíte completa (196 testes) passando.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- app/routers/contatos.py: CLIENTE_CONTATO.TELEFONE passa a ser gravado
sempre com DDI 55 (normalizar_telefone_com_ddi, mesma normalização já
usada em CLIENTE.TELEFONE/CELULAR) — confirmado no node '10 - Validar
Telefones' do workflow n8n de produção (ER6Piy9yu93uOVuY) que o
telefone é usado exatamente como está gravado, sem nenhuma
normalização própria. Um contato cadastrado pelo painel sem o
funcionário digitar '55' na frente nunca recebia a chave automática
por WhatsApp — falha silenciosa, sem nenhum erro visível.
- app/db/clientes.py, app/db/contatos.py, app/db/funcionarios.py:
excluir_cliente/atualizar_contato/excluir_contato/redefinir_senha
passam a retornar bool (cur.rowcount > 0) em vez de None — um
ID inexistente não gera erro nenhum do MySQL (WHERE só não bate com
nada), então sem checar rowcount o router não tinha como saber que
não alterou/excluiu nada.
- app/routers/clientes.py, app/routers/contatos.py,
app/routers/funcionarios.py: editar/excluir/redefinir-senha/
alternar-ativo passam a checar essa existência e mostrar 'não
encontrado' em vez de registrar 'sucesso' no log e redirecionar pra
uma tela que também vai dizer que não encontrou nada.
- app/templates/clientes_lista.html: busca não estava com urlencode nos
links de paginação/ordenação — um termo de busca com '&' (comum em
razão social, ex. 'Fulano & Cia') cortava o resto da query string do
link, perdendo página/ordenação.
Suíte completa (192 testes) passando, com 13 testes novos cobrindo os
casos de 'não encontrado' e o DDI do telefone de contato.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- app/rate_limit.py: bloqueia por IP após 5 tentativas de senha erradas
em 5 minutos em POST /login (rota pré-autenticação, hoje sem nenhuma
proteção contra força bruta).
- Cookie de sessão (routers/auth.py) e cookie CSRF (main.py) passam a
sair com Secure quando a requisição chega em HTTPS (request.url.scheme)
— hoje é sempre False porque produção ainda serve HTTP puro, mas o
cookie passa a ficar protegido automaticamente assim que um TLS for
colocado na frente, sem precisar editar isso de novo.
- main.py: novo middleware de security headers (X-Content-Type-Options,
X-Frame-Options: DENY, Referrer-Policy, Content-Security-Policy,
Strict-Transport-Security condicional a HTTPS).
- requirements.txt: fastapi 0.115.0 -> 0.115.6 (starlette >=0.40.0,
corrige DoS por memória não limitada ao parsear multipart/form-data —
GHSA-f96h-pmfr-66vw), python-multipart 0.0.9 -> 0.0.20 (várias
vulnerabilidades de DoS corrigidas), jinja2 3.1.4 -> 3.1.6 (escapes de
sandbox). httpx/uvicorn/PyMySQL já estavam em versões sem CVE
conhecida — conferido via osv.dev.
- Dockerfile: container não roda mais como root.
Suíte completa (181 testes) passando, incluindo testes novos para o
rate limiter (unitários + integração via POST /login) e smoke test
manual confirmando headers, fluxo de login com CSRF e o bloqueio por
força bruta.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Endereca 5 achados da revisao final de branch:
- Important #1: normalizar_telefone_com_ddi agora e idempotente (nao duplica 55 independente do tamanho)
- Important #2: mascara de telefone detecta DDI colado por tamanho total (12/13 digitos), nao mais so pelo campo especifico
- Minor #3: buscarCnpj pula lookup na Receita quando DV do CNPJ e invalido, preservando a mensagem de erro
- Minor #4: detectarEValidarDocumento roda uma vez no carregamento da pagina
- Minor #5: autofill da Receita dispara evento input para acionar as mascaras de UF/CEP/TELEFONE
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
O algoritmo (CHAVE_INTERNA + funcoes gerarReativacao/gerarInstalacao)
ficava 100% legivel no codigo-fonte da pagina — qualquer pessoa com
acesso ao painel conseguia abrir o DevTools e extrair a logica pra
gerar chaves de licenca fora do sistema.
Move tudo pra app/gerador_chave.py (roda so no servidor) e expoe via
POST /gerador-chave/gerar (autenticado, protegido por CSRF). A pagina
agora so manda os dados do formulario e mostra o resultado que volta
do backend. Algoritmo verificado byte-a-byte contra os 3 exemplos
reais conhecidos (cliente 000001, ALUGUEL). Bonus: o byte inicial
agora usa secrets.randbelow (CSPRNG) em vez de Math.random() do JS.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Gerador de chave: filtra caracteres nao numericos do campo codigo em
tempo real (input event), alem da validacao ja existente no submit.
Tabelas: a.botao/button ganham white-space: nowrap, corrigindo o
botao 'Abrir' (e outros) quebrando em duas linhas depois que as
celulas de dado passaram a permitir quebra de texto longo.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
.login-cartao button { width: 100%; } tinha mais especificidade CSS
que .botao-ver-senha e forcava o botao do olho a ocupar 100% da
largura do campo, jogando o icone (alinhado por padrao a esquerda no
flex) pro canto esquerdo por cima do input. Regra do width:100% agora
so mira o botao de submit; o campo de senha vira um wrapper flex
(input + botao lado a lado), mais robusto que posicionamento absoluto.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sidebar antes esticava junto com o conteudo (align-items:stretch do
flex), entao em telas com tabela longa (Clientes) o rodape (tema/sair/
recolher) ficava fora da area visivel sem rolar a pagina toda. Agora
fica position:sticky, presa ao topo da viewport, com rolagem propria
se a lista de links for maior que a tela.
Login ganha um botao de olho pra mostrar/ocultar a senha digitada.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
O white-space: nowrap em toda celula de tabela impedia o texto de
quebrar linha, entao valores longos de Razao/Fantasia/CNPJ estouravam
a largura da tabela e forcavam rolagem horizontal mesmo em telas
grandes. Cabecalhos continuam sem quebra (nomes curtos); celulas de
dado agora quebram linha quando necessario.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adiciona class condicional (ativo se request.url.path.startswith(...))
em cada link de app/templates/base.html, usando o estilo .sidebar-nav
a.ativo que ja existia em estilo.css mas nunca era aplicado.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- --cor-acento-contraste passa de branco pra #17171a (texto escuro sobre
fundo laranja em botoes, unificando com o tema escuro) - contraste ~7:1
- novo token --cor-acento-texto (#B3510C no tema claro, #F5821F no
tema escuro) usado para laranja como cor de TEXTO sobre fundo claro
(link padrao, badge-acento, item ativo da sidebar, chave gerada em
gerador_chave.html) - contraste 5.12:1 contra branco, acima do
minimo AA de 4.5:1
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>