portal_publico/docs/links-ferramentas-acessos-gerais.md

51 lines
17 KiB
Markdown

# Links & Ferramentas / Acessos Gerais
> Movido do `CLAUDE.md` da raiz em 2026-08-26 para reduzir conflito de edição entre aplicações. Ver `CLAUDE.md` na raiz para arquitetura geral/transversal do Portal (modelo de permissões, API, CSS, etc.). Este arquivo não é auto-carregado pelo Claude Code (não há pacote Python dedicado, o código vive em `portal_api/models.py`/`views.py`/`serializers.py` junto com o resto) — leia manualmente ao mexer nesta aplicação.
## Links & Ferramentas
"Links & Ferramentas" era uma seção do menu com uma única aplicação (a grade de cartões); virou uma seção com **duas** aplicações reais — a grade de cartões original e "Acessos Gerais" (ver seção própria abaixo) — quando essa segunda foi adicionada. Por isso o item do menu, que antes era um link direto (`<li data-section="links-ferramentas"><a href="links-ferramentas.html">`), agora é um `nav-group` expansível (mesmo padrão de "Portais"/"Auditorias") com dois `nav-subitem`: "Links & Ferramentas" (`data-app="links-ferramentas-visualizar"`, mesma URL de antes) e "Acessos Gerais" (`data-app="acessos-gerais-visualizar"`, `acessos-gerais.html`). Isso teve um efeito colateral em `favorites.js`: como o `<li data-section="links-ferramentas">` não tem mais um `<a class="nav-item">` direto (virou um `<button data-group-toggle>`), a seção como um todo deixou de ser favoritável — só os dois sub-itens são, cada um com seu próprio `app_id` derivado (`"links-ferramentas__links-ferramentas"`/`"links-ferramentas__acessos-gerais"`, ver `docs/favoritos.md`). Um favorito antigo com `app_id === "links-ferramentas"` (de antes dessa mudança) para de casar — mesma categoria de caveat já documentada em `docs/favoritos.md`: mudar a forma como um item aparece no menu muda o `app_id` derivado.
`LinkFerramenta` é uma lista **global/compartilhada** (sem FK pra `Usuario`, ao contrário de `Favorito`/`WidgetUsuario`/`NotificacaoDispensada`) — todo usuário com `apps["links-ferramentas-visualizar"]=True` em `permissoes["links-ferramentas"]` vê os mesmos cartões via `GET /api/links-ferramentas/` (ver "Padrão visualizar/editar", `CLAUDE.md` na raiz).
`links-ferramentas.js` também gateia o **conteúdo da própria página** por `apps["links-ferramentas-visualizar"]` (`#lf-no-access`/`#lf-content` em `links-ferramentas.html`, mesmo padrão do `.no-access` de `portal.html`) — isso existe porque o sidebar (`data-section="links-ferramentas"` em `access.js`) só esconde o `nav-group` inteiro com base no `enabled` do módulo (e cada sub-item individualmente com base no seu `-visualizar`), então alguém sem `apps["links-ferramentas-visualizar"]` mas que navegue direto pra URL (ou tenha `enabled=true` sem essa flag, uma combinação tecnicamente possível já que são independentes) via GET no backend recebia 403 e via a tela renderizada com "Nenhum link cadastrado ainda." em vez de uma mensagem de acesso negado. Ao adicionar uma aplicação nova no padrão visualizar/editar, replicar esse gate (como `acessos-gerais.js` já faz) — não basta confiar em `access.js` escondendo o link do menu.
Só quem tem `apps["links-ferramentas-editar"]=True` (`me.permissoes_efetivas["links-ferramentas"].apps["links-ferramentas-editar"]`, já unido no servidor) vê em `links-ferramentas.html` os controles de administração: botão "Adicionar Link" no topo e, em cada cartão, setas de mover para cima/baixo + X de remover (`links-ferramentas.js`, gated no frontend por essa flag, e reforçado no servidor por `PermissaoApp("links-ferramentas", "links-ferramentas-editar")`/`PermissaoApp("links-ferramentas", "links-ferramentas-visualizar")` conforme o método HTTP).
- **Ordenação**: campo `ordem` (inteiro, sem `unique`) em `LinkFerramenta`, `Meta.ordering = ["ordem", "id"]`. Não existe endpoint de reorder em lote no backend — o cliente é quem decide quais `PATCH`es disparar. Duas formas de reordenar na UI, ambas em `links-ferramentas.js`: as setas (`swapOrdem()`) trocam o `ordem` de dois itens adjacentes com duas chamadas `PATCH`; arrastar um cartão (drag-and-drop nativo HTML5, `.lf-card--draggable`/`dragstart`/`dragover`/`drop` no `#lf-grid`) recalcula a lista inteira em memória e envia um `PATCH` só para os itens cujo `ordem` (índice na nova ordem) realmente mudou — como não há `unique` em `ordem`, não tem problema disparar essas chamadas em paralelo (`Promise.all`) mesmo que dois itens fiquem com o mesmo valor por um instante. Ao criar um link novo, o servidor sempre calcula `ordem = max(ordem atual) + 1` em `LinkFerramentaViewSet.perform_create` — qualquer `ordem` enviada pelo cliente no POST é ignorada.
- **Ícone**: `icone` é um `ImageField` opcional (upload real, não URL) — exige Pillow (`requirements.txt`) e `MEDIA_URL`/`MEDIA_ROOT` (`settings.py`, servido em `DEBUG` por `config/urls.py`). Sem ícone, o cartão cai num SVG de fallback (`PID_LINK_DEFAULT_ICON` em `links-ferramentas.js`, mesmo glifo do item "Links & Ferramentas" no menu). O upload viaja como `multipart/form-data` (`FormData`), não JSON. Limite de tamanho: **2MB**, checado em dois lugares — `validar_tamanho_icone_link` (validator do campo `icone` em `models.py`, é a checagem que vale de verdade, roda via `LinkFerramentaSerializer.is_valid()`) e uma checagem espelhada em `links-ferramentas.js` (`PID_LINK_ICON_MAX_BYTES`, no `change` do input e de novo antes do POST/PATCH) só para dar feedback sem esperar a resposta do servidor. Ao mudar o limite, atualizar os dois lados (e gerar migração — `validators` no campo entra no `deconstruct()`).
- Clicar num cartão sempre abre a URL numa aba nova (`target="_blank"`) — são links externos por definição, não faz sentido navegar embutido no portal.
- Editar um cartão existente (nome, URL e ícone) usa o mesmo modal de "Adicionar Link" (`#lf-add-modal`), reaproveitado em modo edição — o botão de lápis em cada cartão (visível só com `apps["links-ferramentas-editar"]`, ao lado das setas de mover) chama `openModal(link)` pré-preenchendo os campos; salvar despacha `PATCH /api/links-ferramentas/{id}/` (`pidUpdateLink`, multipart igual ao POST) em vez de criar um novo. O campo de ícone fica sempre vazio ao abrir em modo edição (input `type="file"` não aceita valor pré-preenchido por segurança do browser) — não enviar o campo `icone` no PATCH mantém o ícone atual; só enviar substitui.
- **Favoritos por link** (`LinkFerramentaFavorito`, model dedicado — não confundir com `Favorito`, que marca aplicações inteiras do menu, ver `docs/favoritos.md`): estrela em cada cartão (`.lf-card__favorite`, visível pra qualquer um com `apps["links-ferramentas-visualizar"]`, independente de editar) via `POST`/`DELETE /api/links-ferramentas-favoritos/{link_id}/` (natural key é o `id` do link, igual ao padrão `app_id`/`notif_id` de `Favorito`/`NotificacaoDispensada`). Só afeta a **ordem de exibição dentro da própria tela** — `links-ferramentas.js` busca `links` e favoritos em paralelo e reordena em memória (`sortFavoritesFirst()`) pra mostrar favoritos primeiro, preservando o `ordem` relativo dentro de cada grupo; o `ordem` compartilhado do `LinkFerramenta` nunca é tocado por favoritar/desfavoritar. Simplificação deliberada: as setas de mover e o drag-and-drop operam sobre esse mesmo array já reordenado (`links`), então se um usuário com `apps["links-ferramentas-editar"]` também tiver favoritos marcados, arrastar um cartão persiste a ordem "favoritos primeiro" que ele está vendo como o novo `ordem` compartilhado — aceitável pro tamanho do app, mas vale lembrar se o comportamento parecer estranho.
- **Widget "Links Favoritos"** (`links-favoritos` em `PID_WIDGET_TYPES`, `static/js/widgets.js`, ver `docs/calendario-individual.md` pro resto do sistema de widgets): lista em `portal.html` só os links favoritados, cada linha com ícone pequeno (`.widget-links-list__icon`, fallback `PID_WIDGET_LINK_DEFAULT_ICON` — cópia local do glifo de `PID_LINK_DEFAULT_ICON`, já que `widgets.css` não carrega `links-ferramentas.css`) + nome, a linha inteira é um `<a target="_blank">` pro mesmo destino do cartão original. Depende de `pidFetchLinks`/`pidFetchLinkFavoritos`, então `links-ferramentas.js` foi incluído em `portal.html` só por causa dessas funções de dados — seu handler de `DOMContentLoaded` retorna cedo lá (`if (!grid) return`, não existe `#lf-grid` em `portal.html`), mesmo padrão de guarda de `profiles.js`/`widgets.js`.
## Acessos Gerais
Segunda aplicação da seção "Links & Ferramentas" (ver acima) — um cadastro de acessos/logins compartilhados (ex.: "login geral de um site"), organizado em **seções e linhas** (inspirado numa tela do Asana que o usuário mostrou como referência): cada seção agrupa várias linhas, e clicar numa linha abre um popup com os detalhes daquele acesso. Dois models novos, sem relação com `LinkFerramenta`:
- `AcessoGeralSecao` (`nome`, `ordem`, `perfis_restritos` M2M pra `PerfilAcesso`, ver abaixo) — só a categoria (ex.: "Banco de Dados/API"). Lista compartilhada, sem FK pra `Usuario`.
- `AcessoGeral` (`secao` FK, `nome`, `url`, `usuario`, `senha`, `observacoes`, `ordem`) — a linha em si. `senha` é um `CharField` em texto puro (não há criptografia/hash — é um cadastro de referência entre a própria equipe, não um cofre de senhas robusto; se isso precisar mudar no futuro, confirmar com o usuário antes, já que envolve infraestrutura de chave/criptografia nova). `observacoes` guarda **HTML sanitizado** (ver "Observações ricas" abaixo), com um `validators=[validar_tamanho_observacoes_acesso]` (`models.py`) limitando a 2.000.000 caracteres — generoso o bastante pra várias imagens embutidas, mas evita um `TextField` sem limite nenhum crescer sem controle.
**Permissão**: mesmo padrão visualizar/editar de Links & Ferramentas, com chaves próprias (`acessos-gerais-visualizar`/`acessos-gerais-editar`, ver "Padrão visualizar/editar" no `CLAUDE.md` da raiz) — `AcessoGeralSecaoViewSet`/`AcessoGeralViewSet` (`views.py`) instanciam `PermissaoApp("links-ferramentas", app_key)` com a chave certa por método HTTP. `acessos-gerais.js` gateia o conteúdo da própria página (`#ag-no-access`/`#ag-content`) por `acessos-gerais-visualizar`, mesmo raciocínio do gate de `links-ferramentas.js`.
**Restrição de seção por perfil** (`AcessoGeralSecao.perfis_restritos`): além da permissão de módulo, cada seção pode opcionalmente ser restrita a um subconjunto de `PerfilAcesso` — `perfis_restritos` vazio (padrão) = visível a qualquer um com `acessos-gerais-visualizar`; não vazio = só quem também tiver um desses perfis vinculado. Isso é uma restrição de **dado**, independente da árvore de permissões (não precisa mexer em Perfis de Acesso pra configurar) — é escolhida direto no modal "Adicionar Seção"/"Renomear Seção" (`#ag-secao-form-perfis`, um `.checklist-box` com todos os perfis cadastrados, populado via `pidFetchPerfis()`). O filtro é aplicado em dois lugares no backend, ambos em `views.py`:
- `AcessoGeralSecaoViewSet.get_queryset()` — só devolve seções sem restrição ou com interseção entre `perfis_restritos` e os perfis do usuário logado; `AcessoGeralViewSet.get_queryset()` aplica o mesmo filtro via `secao__perfis_restritos`, pra uma linha nunca vazar de uma seção que o usuário não veria.
- `AcessoGeralSerializer.__init__` também restringe o próprio campo `secao` (o `PrimaryKeyRelatedField` que valida o POST/PATCH) à mesma queryset filtrada — sem isso, alguém com `acessos-gerais-editar` mas sem o perfil exigido conseguiria criar uma linha dentro de uma seção restrita só sabendo o id dela, mesmo sem enxergá-la em nenhuma listagem.
Não há exceção pra `gerencia_permissoes`/perfil de acesso total — mesmo "Integração e Inovação" (código 8) fica de fora de uma seção restrita a outro perfil que não o seu, exatamente como qualquer outro perfil (é uma lista de permissão explícita, não um nível hierárquico).
**Ordenação por seção**: `AcessoGeral.ordem` é **escopada por `secao`** (ao contrário de `LinkFerramenta.ordem`, que é global) — `AcessoGeralViewSet.perform_create` calcula `max(ordem)` só entre as linhas da mesma seção. Reordenar (drag-and-drop nativo HTML5, mesma mecânica de `links-ferramentas.js` — `dragstart`/`dragover`/`drop` em `#ag-sections`, delegado num container que tem todas as seções) só é permitido **dentro de uma seção**: `dragover` ignora o alvo se `draggedAcesso.secao !== targetAcesso.secao`, então uma linha nunca muda de seção arrastando. As setas de mover para cima/baixo (`swapOrdem()`) seguem a mesma regra, já que operam sobre `acessosDaSecao(secao.id)`, nunca a lista inteira. Seções em si não têm drag-and-drop — só criar/renomear/excluir; a ordem entre seções é a de criação (`ordem` incrementado pelo servidor, sem UI de reordenar).
**Observações ricas (texto + imagens embutidas)**: o campo "Observações" do modal de acesso (`#ag-form-observacoes`) é um `<div contenteditable>`, não um `<textarea>` — permite formatar texto livremente e incluir imagens **sem nenhum botão dedicado**: colar (`Ctrl+V`, evento `paste`, lido de `event.clipboardData.items`) ou arrastar um arquivo de imagem pra dentro do campo (evento `drop`, com `dragover` chamando `preventDefault()` pra permitir o drop) — as duas vias caem na mesma função `insertImageFile()` em `acessos-gerais.js`. A imagem (até **2MB**, `PID_AG_IMAGE_MAX_BYTES`, checado antes de inserir) vira uma data URI via `FileReader.readAsDataURL` e é inserida com `document.execCommand("insertImage", ...)` — sem upload de arquivo separado, fica embutida no próprio HTML salvo em `observacoes`. No caminho de `paste` o cursor já está na posição certa (o navegador só troca o clipboard, não move o foco); no de `drop`, `placeCaretAtPoint()` usa `document.caretRangeFromPoint`/`caretPositionFromPoint` (conforme suporte do browser) pra posicionar o cursor exatamente onde o arquivo foi solto antes de inserir.
- **Sanitização (`nh3`)**: como esse HTML é gerado por quem tem `acessos-gerais-editar` mas renderizado via `innerHTML` pra qualquer um com `acessos-gerais-visualizar`, ele passa por um allowlist estrito no backend antes de salvar — `AcessoGeralSerializer.validate_observacoes()` roda `nh3.clean()` permitindo apenas tags de texto básicas + `<img>` (`RICHTEXT_ALLOWED_TAGS`/`_ATTRS`/`_SCHEMES` no topo de `serializers.py` — generalizadas nessas constantes desde que o texto de "Mais informações" de uma aplicação passou a reaproveitar o mesmo allowlist, ver "Ajuda de aplicação" no `CLAUDE.md` da raiz) — **sem `<a>`/`<script>`/atributos de evento** (`onerror` etc. são descartados por não estarem na allowlist de atributos). `url_schemes` inclui `"data"` de propósito, já que as imagens embutidas são `data:image/...;base64,...`, não URLs externas. Isso significa que o campo é reprocessado no servidor mesmo que o cliente já não deixe inserir nada além de texto/imagem pela UI — defesa em profundidade contra alguém montando o payload na mão. `nh3` é o binding Python da lib Rust "ammonia" (`requirements.txt`) — foi escolhido no lugar do `bleach` (usado numa primeira versão desta funcionalidade) porque o `bleach` está oficialmente sem manutenção desde 2023 e parou de receber releases (inclusive de segurança) em 2026; `nh3` tem API quase idêntica (`clean(html, tags=set[...], attributes=dict[...], url_schemes=set[...])`, allowlist do mesmo jeito) e é o substituto recomendado pelos próprios mantenedores do bleach.
- Ao carregar um acesso existente pra editar, `formObservacoes.innerHTML = acesso.observacoes` repopula o editor com o HTML já sanitizado (imagens inclusas); salvar lê `formObservacoes.innerHTML` (função `observacoesValue()`, que retorna string vazia se não houver nem texto nem `<img>`, evitando salvar lixo tipo um `<br>` solto de um editor "vazio").
**Popup de detalhes** (`#ag-view-modal`, `acessos-gerais.js`): mostra nome, URL (link clicável), usuário, senha e observações — cada campo (`.ag-view-field`) só aparece se tiver valor (`hidden` quando vazio). A senha começa mascarada (`"••••••••"`, com o valor real guardado em `viewSenha.dataset.value`) e um botão de olho alterna pra o valor real — a máscara é feita trocando o próprio `textContent`, não com CSS (`-webkit-text-security` não é suportado em todos os browsers e deixaria a senha real exposta no DOM seletável mesmo "mascarada" visualmente nesses casos). As observações são renderizadas via `innerHTML` (não `textContent`, ao contrário dos outros campos) já que podem conter as imagens embutidas — seguro porque o HTML já veio sanitizado do backend; o container é uma `<div class="ag-view-observacoes">` (não `<p>`, que não pode conter `<img>`/`<div>` sem gerar HTML inválido). Com `acessos-gerais-editar`, o popup também mostra "Editar"/"Excluir"; "Editar" fecha o popup e abre o mesmo modal de formulário (`#ag-form-modal`) usado por "Adicionar Acesso", pré-preenchido.
Nenhuma tela recalcula união de departamentos/liderança aqui — é uma aplicação isolada, sem relação com `Usuario` além da permissão de quem pode ver/editar (e, agora, do `perfis_restritos` por seção).
## CSS
`links-ferramentas.css` (`.lf-*`) e `acessos-gerais.css` (`.ag-*`) — um arquivo por página, só usados em suas respectivas telas.