Portal da Cidade e Magic-Link Auth
Oficiais de compliance municipal nao querem outra senha. O portal da cidade do Levy em /city/{slug} usa auth por magic link baseado em e-mail -- digite seu e-mail, clique no link, voce esta dentro. A sessao e assinada por HMAC, dura 24 horas e e limitada a uma unica jurisdicao.
Quem pode fazer login
Apenas e-mails listados em city_contacts com portal_access = true podem se autenticar. Adicionar um contato municipal acontece no dashboard do operador, nao no portal em si -- nao ha auto-cadastro.
Estrutura de URL
| Pagina | URL | Proposito |
|---|---|---|
| Login | /city/{slug} | Inserir e-mail, solicitar magic link |
| Callback de auth | /api/city/{slug}/auth/callback?token=<tok> | Consumir magic link, definir cookie de sessao, redirecionar para dashboard |
| Dashboard | /city/{slug} (pos-auth) | Scoreboard de compliance + links para outras visoes |
| Mapa de frota | /city/{slug}/fleet | Veiculos dentro da bbox da jurisdicao |
| Heatmap de viagens | /city/{slug}/trips | Densidade de viagens em buckets de tempo |
| Corredores de estacionamento | /city/{slug}/parking-corrals | Snapshots de utilizacao |
| Relatorio de compliance | /city/{slug}/compliance-report?period=monthly&date=2026-05 | Baixar CSV/PDF |
O slug corresponde a mds_jurisdictions.slug. Um slug = uma jurisdicao = uma bbox.
Fluxo de magic link
Contato insere seu e-mail
Em /city/{slug}, o formulario faz POST para POST /api/city/{slug}/auth/magic-link com { email }. A rota sempre retorna HTTP 200 -- nao revelamos se o e-mail existe em city_contacts (evita enumeracao).
Token gerado e enviado por e-mail
Se o e-mail corresponde a uma linha city_contacts com portal_access = true para este slug, a rota gera um token de uso unico, armazena seu hash sha256 + expires_at = now() + 15 minutos na linha do contato e envia o link por e-mail.
Contato clica no link
O link vai para /api/city/{slug}/auth/callback?token=<tok>. A rota faz hash do token, busca a linha city_contacts correspondente, verifica expires_at, marca o token como consumido e emite um cookie de sessao.
Cookie de sessao definido
Set-Cookie: levy_city_session=<hmac>; Path=/city/<slug>; HttpOnly; Secure; SameSite=Lax; Max-Age=86400. O payload do cookie codifica city_contact_id, jurisdiction_id, issued_at. Assinado por HMAC com um segredo do lado do servidor.
Redirecionamento para /city/{slug}
Agora mostra o dashboard pos-auth. Cada requisicao subsequente a /api/city/{slug}/* verifica o HMAC e corta a resposta para a bbox.
O que o contato ve
O dashboard mostra:
- Scoreboard de compliance -- pass/fail por linha
permit_conditions, rolling de 30 dias - Snapshot de hoje -- contagem de frota implantada, viagens, reclamacoes abertas
- Politicas ativas -- as regras ingeridas de seu proprio feed de politica (sanity check)
- Links -- para mapa de frota, heatmap de viagens, corredores de estacionamento, relatorio mensal
Tudo abaixo do snapshot e somente leitura. O portal nao deixa a cidade editar dados do operador -- eles editam seu proprio feed de politica e nos reingerimos.
Corte por bbox
Toda rota da API do portal da cidade e cortada para a bbox da jurisdicao antes de retornar dados. O corte e aplicado no lado do servidor, nao na camada UI:
// Dentro de cada rota /api/city/{slug}/*
const session = await verifyCitySession(request);
const jurisdiction = await getJurisdiction(session.jurisdiction_id);
const bbox = jurisdiction.bbox; // [minLng, minLat, maxLng, maxLat]
const vehicles = await sb
.from('vehicles')
.select('id, last_lat, last_lng, status, vehicle_number')
.gte('last_lng', bbox[0])
.lte('last_lng', bbox[2])
.gte('last_lat', bbox[1])
.lte('last_lat', bbox[3]);
Um contato municipal nunca vera um veiculo fora de sua fronteira, mesmo que exista na mesma subconta.
Para o heatmap de viagens, o corte e realizado no ponto de inicio da viagem -- uma viagem que comecou dentro da jurisdicao e terminou fora ainda aparece no heatmap. E o que cidades esperam para analises de origem de viagem.
Expiracao de sessao + refresh
Sessoes duram 24 horas. Nao ha fluxo de refresh -- o contato solicita um novo magic link quando a sessao expira. Isso e deliberado: um analista municipal perdendo acesso por algumas horas e passando pelo movimento do magic link novamente e aceitavel; sessoes longas no portal da cidade nao sao.
O segredo HMAC pode ser rotacionado configurando um novo valor na env config e redeployando. Sessoes assinadas com o segredo antigo falham imediatamente a verificacao HMAC e o contato recebe um prompt de re-login limpo.
Multiplos contatos por jurisdicao
Uma jurisdicao pode ter qualquer numero de linhas city_contacts. Padrao comum:
| Papel | |
|---|---|
| Oficial principal de compliance | sarah@city.gov |
| Revisor de licenca | pat@city.gov |
| Analista Populus / Ride Report (consultor) | analyst@populus.ai |
Todos os tres veem os mesmos dados, todos os tres recebem e-mails de digest em sua propria cadencia. Sessoes sao independentes -- um contato fazendo logout nao afeta os outros.
Revogando acesso
Para revogar acesso de um contato:
- Abra
/dashboard/compliance/{jurisdiction-id}-> Contatos municipais. - Defina
portal_access = falsena linha, ou delete-a totalmente. - Cookies de sessao ativos nesse contato falharao a verificacao do lado do servidor na proxima requisicao -- o cookie ainda e HMAC valido, mas a linha do contato nao permite mais acesso ao portal.
Magic links ja emitidos para esse contato tambem sao invalidados -- auth/callback rejeita tokens para contatos onde portal_access = false.
Localizacao
O portal e renderizado na preferencia de idioma do contato (city_contacts.locale, padrao en). E-mails de digest tambem usam a mesma preferencia. Lancamento suporta en e es; locales adicionais voltam para ingles.
Solucao de problemas
- Contato diz "nao recebi o e-mail" -- verifique spam, depois confirme que o e-mail corresponde a
city_contacts.emailexatamente (insensivel a caixa). Reenvie inserindo o e-mail no formulario do portal novamente. - Link diz "expirado" -- magic links vivem 15 minutos. O contato deve solicitar um novo.
- Link diz "invalido" -- o token ja foi consumido, a linha do contato foi deletada ou
portal_accessfoi definido como false. Investigue no dashboard do operador. - Cookie de sessao nao esta sendo definido -- o contato esta em um navegador que bloqueia cookies de terceiros e
/city/{slug}esta sendo carregado dentro de um iframe em algum lugar. Evite embeds de iframe.
Proximos passos
- E-mails de Digest e Cadencia -- o que o contato recebe na caixa de entrada.
- Relatorios de Condicoes de Licenca -- o que o scoreboard do dashboard esta verificando.