17 KiB
Links & Ferramentas / Acessos Gerais
Movido do
CLAUDE.mdda raiz em 2026-08-26 para reduzir conflito de edição entre aplicações. VerCLAUDE.mdna 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 emportal_api/models.py/views.py/serializers.pyjunto 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/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/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, semunique) emLinkFerramenta,Meta.ordering = ["ordem", "id"]. Não existe endpoint de reorder em lote no backend — o cliente é quem decide quaisPATCHes disparar. Duas formas de reordenar na UI, ambas emlinks-ferramentas.js: as setas (swapOrdem()) trocam oordemde dois itens adjacentes com duas chamadasPATCH; arrastar um cartão (drag-and-drop nativo HTML5,.lf-card--draggable/dragstart/dragover/dropno#lf-grid) recalcula a lista inteira em memória e envia umPATCHsó para os itens cujoordem(índice na nova ordem) realmente mudou — como não háuniqueemordem, 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 calculaordem = max(ordem atual) + 1emLinkFerramentaViewSet.perform_create— qualquerordemenviada pelo cliente no POST é ignorada. - Ícone:
iconeé umImageFieldopcional (upload real, não URL) — exige Pillow (requirements.txt) eMEDIA_URL/MEDIA_ROOT(settings.py, servido emDEBUGporconfig/urls.py). Sem ícone, o cartão cai num SVG de fallback (PID_LINK_DEFAULT_ICONemlinks-ferramentas.js, mesmo glifo do item "Links & Ferramentas" no menu). O upload viaja comomultipart/form-data(FormData), não JSON. Limite de tamanho: 2MB, checado em dois lugares —validar_tamanho_icone_link(validator do campoiconeemmodels.py, é a checagem que vale de verdade, roda viaLinkFerramentaSerializer.is_valid()) e uma checagem espelhada emlinks-ferramentas.js(PID_LINK_ICON_MAX_BYTES, nochangedo 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 —validatorsno campo entra nodeconstruct()). - 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ó comapps["links-ferramentas-editar"], ao lado das setas de mover) chamaopenModal(link)pré-preenchendo os campos; salvar despachaPATCH /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 (inputtype="file"não aceita valor pré-preenchido por segurança do browser) — não enviar o campoiconeno PATCH mantém o ícone atual; só enviar substitui. - Favoritos por link (
LinkFerramentaFavorito, model dedicado — não confundir comFavorito, que marca aplicações inteiras do menu, verdocs/favoritos/favoritos.md): estrela em cada cartão (.lf-card__favorite, visível pra qualquer um comapps["links-ferramentas-visualizar"], independente de editar) viaPOST/DELETE /api/links-ferramentas-favoritos/{link_id}/(natural key é oiddo link, igual ao padrãoapp_id/notif_iddeFavorito/NotificacaoDispensada). Só afeta a ordem de exibição dentro da própria tela —links-ferramentas.jsbuscalinkse favoritos em paralelo e reordena em memória (sortFavoritesFirst()) pra mostrar favoritos primeiro, preservando oordemrelativo dentro de cada grupo; oordemcompartilhado doLinkFerramentanunca é 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 comapps["links-ferramentas-editar"]também tiver favoritos marcados, arrastar um cartão persiste a ordem "favoritos primeiro" que ele está vendo como o novoordemcompartilhado — aceitável pro tamanho do app, mas vale lembrar se o comportamento parecer estranho. - Widget "Links Favoritos" (
links-favoritosemPID_WIDGET_TYPES,static/js/widgets.js, verdocs/calendario-individual/calendario-individual.mdpro resto do sistema de widgets): lista emportal.htmlsó os links favoritados, cada linha com ícone pequeno (.widget-links-list__icon, fallbackPID_WIDGET_LINK_DEFAULT_ICON— cópia local do glifo dePID_LINK_DEFAULT_ICON, já quewidgets.cssnão carregalinks-ferramentas.css) + nome, a linha inteira é um<a target="_blank">pro mesmo destino do cartão original. Depende depidFetchLinks/pidFetchLinkFavoritos, entãolinks-ferramentas.jsfoi incluído emportal.htmlsó por causa dessas funções de dados — seu handler deDOMContentLoadedretorna cedo lá (if (!grid) return, não existe#lf-gridemportal.html), mesmo padrão de guarda deprofiles.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_restritosM2M praPerfilAcesso, ver abaixo) — só a categoria (ex.: "Banco de Dados/API"). Lista compartilhada, sem FK praUsuario.AcessoGeral(secaoFK,nome,url,usuario,senha,observacoes,ordem) — a linha em si.senhaé umCharFieldem 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).observacoesguarda HTML sanitizado (ver "Observações ricas" abaixo), com umvalidators=[validar_tamanho_observacoes_acesso](models.py) limitando a 2.000.000 caracteres — generoso o bastante pra várias imagens embutidas, mas evita umTextFieldsem 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 entreperfis_restritose os perfis do usuário logado;AcessoGeralViewSet.get_queryset()aplica o mesmo filtro viasecao__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 camposecao(oPrimaryKeyRelatedFieldque valida o POST/PATCH) à mesma queryset filtrada — sem isso, alguém comacessos-gerais-editarmas 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 temacessos-gerais-editarmas renderizado viainnerHTMLpra qualquer um comacessos-gerais-visualizar, ele passa por um allowlist estrito no backend antes de salvar —AcessoGeralSerializer.validate_observacoes()rodanh3.clean()permitindo apenas tags de texto básicas +<img>(RICHTEXT_ALLOWED_TAGS/_ATTRS/_SCHEMESno topo deserializers.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" noCLAUDE.mdda raiz) — sem<a>/<script>/atributos de evento (onerroretc. são descartados por não estarem na allowlist de atributos).url_schemesinclui"data"de propósito, já que as imagens embutidas sãodata: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 dobleach(usado numa primeira versão desta funcionalidade) porque obleachestá oficialmente sem manutenção desde 2023 e parou de receber releases (inclusive de segurança) em 2026;nh3tem 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.observacoesrepopula o editor com o HTML já sanitizado (imagens inclusas); salvar lêformObservacoes.innerHTML(funçãoobservacoesValue(), 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.