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

17 KiB

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" 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 PATCHes 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.