portal_publico/docs/perfis-usuarios/perfis-usuarios.md

20 KiB
Raw Permalink Blame History

Perfis de Acesso / Usuários (telas administrativas)

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 o modelo de permissões em si (formato do JSON de PerfilAcesso.permissoes, padrão visualizar/editar, permissoes_efetivas()) — esse conteúdo é transversal e continua lá porque toda aplicação do Portal depende dele. Este arquivo cobre só as telas administrativas (perfis-acesso.html/usuarios.html) em si. Não é auto-carregado pelo Claude Code — leia manualmente ao mexer nessas duas telas.

Popup "Nova Aplicação" (gerenciar acesso por aplicação, entre perfis)

Botão #pa-app-search-btn ao lado de "Novo Perfil" (perfis-acesso.html) abre #pa-app-search-modal — o caminho inverso da árvore de permissões: em vez de abrir um perfil e marcar módulo por módulo, o usuário busca uma aplicação/ferramenta pelo nome e vê/gerencia todos os perfis que têm acesso a ela numa tabela só.

  • Fonte dos dados — 100% reaproveitado, sem endpoint novo: pidFlattenAplicacoes() (profiles.js) achata PID_MODULES × PID_MODULE_APPS (já carregados de GET /api/catalogo/ no load da página) numa lista plana de "aplicações" — uma por entrada de MODULE_APPS, seja ela um app simples ou um subgrupo com tools aninhadas (mesma unidade usada em renderEntry() da árvore). A busca (renderAppSearchResults()) casa o termo contra o label da aplicação, o label do módulo e o label de cada tool aninhada — esse último é o que permite achar, por exemplo, "Controle Simples Nacional" (o tool real dentro do subgrupo "Consultoria Tributária" de Auditorias) mesmo a unidade selecionável sendo o subgrupo inteiro.
  • Painel de gerenciamento (renderAppManageTable()): ao clicar num resultado, mostra uma tabela com uma linha por perfil (profiles, o mesmo array já carregado pela tela) e uma coluna de checkbox por tool — para app simples sem subgrupo, uma coluna única "Acesso". O cabeçalho de cada coluna usa tool.label.split(" (")[0] (corta o parêntese explicativo tipo "Editar (reordenar, incluir e remover cartões)" → "Editar"), sem precisar de um label curto dedicado no catálogo.
  • Salva na hora, por checkbox (decisão explícita do usuário — sem botão "Salvar" no popup): cada change dispara PATCH /api/perfis/{codigo}/ só com {permissoes: perfil.permissions} (pidUpdatePerfilPermissoes, PATCH parcial — o ModelViewSet já aceita, PerfilAcessoSerializer não exige os outros campos fora de partial_update). Erro de rede reverte o checkbox e o estado em memória, com pidAlert() (ver "Modal de confirmação genérico" no CLAUDE.md da raiz — mesmo padrão de outros toggles imediatos do app, ex. inativar usuário).
  • Conceder acesso habilita o módulo automaticamente (decisão explícita do usuário): se o checkbox marcado pertence a um módulo com enabled=false naquele perfil, o toggle também vira perm.enabled = true no mesmo PATCH — sem isso, o perfil ganharia a chave em apps mas o item continuaria escondido no menu (access.js esconde o nav-group/nav-subitem inteiro por enabled, não só por app). Revogar não desabilita o módulo de volta (outras aplicações dele podem seguir em uso por aquele perfil).
  • Perfis inativos (ativo=False) aparecem na tabela com o selo .status-pill--inativo (mesmo componente da coluna "Status" de usuarios.html), sem serem excluídos da lista — nada nesse popup impede gerenciar o acesso deles.

Aba "Usuários do Escritório" (dentro da edição de um perfil) — duas tabelas com seleção múltipla

Substituiu o antigo <select> + botão "Vincular" + lista simples com X pra remover — pedido explícito do usuário pra reestruturar visualmente no estilo de um componente de transferência dupla (referência: uma tela de outro sistema com duas grades lado a lado, cada uma com checkbox de seleção, busca por coluna, ordenação e um botão de ação em lote).

  • Duas tabelas (.pa-users-dual, grid 2 colunas que colapsa pra 1 abaixo de 900px): à esquerda, #pa-users-available-* — todo usuário ativo ainda não vinculado a este perfil; à direita, #pa-users-linked-* — todo usuário ativo já vinculado. Usuário inativo nunca aparece em nenhum dos dois painéis (listaParaPainelUsuarios() filtra usuariosCacheAtual por is_active antes de separar entre vinculado/disponível) — decisão explícita do usuário; na prática, inativar já limpa os perfis de alguém no backend (_revogar_acesso_se_inativo(), ver "Inativar/reativar usuário" abaixo), então esse filtro no frontend é sobretudo defensivo pra dados legados. Cada tabela tem: checkbox de seleção por linha + "selecionar todos" no cabeçalho (#pa-users-available-select-all/#pa-users-linked-select-all, aplica só sobre as linhas filtradas visíveis, mesmo critério de .checklist-select-all), coluna "Nome Usuário" ordenável (clique alterna asc/desc, ícone ↕ que fica --accent quando ativo — mesmo padrão .ua-sort-icon já usado em usuarios.html) e coluna "E-mail" (não ordenável), com uma segunda linha de cabeçalho (.pa-users-table__filters) só com os campos de busca por nome/e-mail — filtro client-side sobre o array já carregado, sem debounce.
  • Botão de atualizar (ícone circular, #pa-users-available-refresh-btn/#pa-users-linked-refresh-btn) refaz GET /api/usuarios/ (refreshUsuarios(), profiles.js) e re-renderiza os dois painéis a partir do mesmo cache — as duas tabelas sempre refletem o mesmo snapshot de usuários, nunca buscam independentemente uma da outra.
  • Vincular/Desvincular em lote: o botão de cada painel (#pa-users-link-btn/#pa-users-unlink-btn, desabilitado enquanto a seleção daquele painel estiver vazia) dispara bulkAlterarVinculo(kind, vincular) — um PATCH /api/usuarios/{id}/ (pidSetUsuarioPerfis) por usuário selecionado, em paralelo (Promise.all), cada um recalculando a própria lista de perfis (adiciona ou remove só o codigo do perfil sendo editado, preservando os demais perfis do usuário). Ao terminar, a seleção é limpa e os dois painéis são recarregados do zero (refreshUsuarios()) — um usuário que acabou de ser vinculado desaparece da tabela da esquerda e aparece na da direita, e vice-versa.
  • Abrir a aba de um perfil diferente (renderUsers(), chamada por openEdit()) sempre reseta os dois painéis: seleção limpa, ordenação de volta pra ascendente, campos de busca vazios — evita carregar o estado de filtro/seleção deixado num perfil anterior.
  • Sem endpoint novo — 100% reaproveitamento de GET /api/usuarios/ (UsuarioListSerializer, já expõe email) e PATCH /api/usuarios/{id}/ (pidSetUsuarioPerfis, já existia).

Inativar/reativar usuário (usuarios.html)

Usa o campo is_active que já vem de AbstractUser — não foi criado nenhum campo/migração novo, só exposto em UsuarioSerializer/UsuarioListSerializer e ligado na UI. is_active=False já é suficiente pro Django bloquear o acesso sozinho, sem nenhum código extra de autenticação:

  • Login novo: authenticate() (usado em login_view) roda via django.contrib.auth.backends.ModelBackend, que internamente chama user_can_authenticate() e recusa (None) qualquer usuário com is_active=False, mesmo com a senha certa. Como isso faz authenticate() retornar None tanto pra senha errada quanto pra usuário inativo, login_view faz uma checagem manual só no caminho de falha (Usuario.objects.filter(username=username, is_active=False).first() + check_password()) pra devolver uma mensagem diferente ("Este usuário está inativo...", 403) só quando a senha bate mas a conta está inativa — sem essa checagem extra, qualquer tentativa com credenciais erradas ou inexistentes continua caindo no genérico "Login ou senha inválidos." (401), pra não revelar se um username existe.
  • Sessão já aberta: também não precisa de nenhum middleware/permissão customizado — ModelBackend.get_user(user_id) (chamado pelo Django a cada request pra popular request.user a partir da sessão) também recusa usuários inativos, então na próxima requisição depois de desativado o usuário vira AnonymousUser automaticamente e IsAuthenticated/PodeGerenciarPermissoes já barram sozinhos. Ou seja: desativar alguém já derruba o acesso na mesma hora, não só impede o próximo login.

Inativar desvincula perfis de acesso e liderança automaticamente (_revogar_acesso_se_inativo(), serializers.py, decisão explícita do usuário): sempre que UsuarioSerializer.create()/update() termina com usuario.is_active=False, perfis é limpo (usuario.perfis.clear()) e o usuário sai do liderados de qualquer gerente que o tivesse (usuario.lideres.clear() — lideres é a relação reversa de Usuario.liderados; limpar aqui remove usuario do lado de quem o lidera, sem afetar quem usuario eventualmente lidera, caso ele mesmo seja gerente). A chamada é sempre a última coisa em create()/update(), depois dos .set() de perfis/departamentos/liderados — colocar antes seria inútil, já que o formulário de edição de usuarios.html sempre reenvia o checklist de perfis inteiro junto com qualquer mudança no checkbox "Usuário ativo", e um .set() posterior desfaria uma limpeza feita cedo demais. Cobre os dois pontos de entrada reais (botão de alternar na lista + checkbox no formulário de edição), ambos passando por PATCH /api/usuarios/{id}/; não há um hook equivalente no admin.py (uso interno, fora de escopo). Não é uma trava: nada impede reativar alguém depois (ele volta sem nenhum perfil/liderança, precisa reconfigurar) nem impede — por ora — que um gerente adicione manualmente um usuário já inativo aos próprios liderados pela tela dele (o hook só dispara ao salvar o usuário inativo em si, não ao salvar o gerente).

Na UI (users-admin.js/usuarios.html): coluna "Status" na lista (.status-pill/.status-pill--ativo/.status-pill--inativo, mesmo componente que já existia pro PerfilAcesso.ativo) e um botão de alternar (ícone de "power") na linha, ao lado de editar/excluir — PATCH /api/usuarios/{id}/ com {is_active: !atual}, com confirmação via window.confirm. O checkbox "Usuário ativo" no formulário de edição faz a mesma coisa (útil quando já se está editando outros campos). Os dois lugares bloqueiam auto-desativação (mesmo padrão de guarda já usado pra "não pode excluir a si mesmo": checagem só no frontend, id === me.id) — no formulário isso aparece como o checkbox desabilitado (disabled) quando account.id === me.id, em vez de um alerta.

Filtro de status na lista (#ua-status-filtros, chips "Ativos"/"Inativos"/"Todos" ao lado do título "Usuários" — mesma linguagem visual de .ind-departamento-chip): client-side, sobre o array users já carregado (statusFiltro em users-admin.js, aplicado em renderList() antes do filtro de busca por texto). Nasce em "ativos" por padrão (decisão explícita do usuário — a lista não deve abrir mostrando quem já foi desativado) e reseta pra "ativos" só no load da página, não a cada renderList().

Colunas de código cadastral + ordenação (#ua-table): a lista também mostra codigo_folha/codigo_questor/codigo_tareffa/codigo_contabit/ramal como colunas próprias (antes só apareciam dentro do formulário de edição) — pedido explícito do usuário pra conseguir achar quem está sem algum desses códigos cadastrado antes de outras aplicações passarem a depender deles, mesmo eles sendo campos opcionais (blank=True) no model. Célula vazia renderiza <span class="ua-campo-vazio">—</span> (itálico, cor apagada) em vez de string vazia, pra ficar visualmente óbvio ao ordenar a coluna. Todo <th data-sort="..."> (login, nome, os 4 códigos, ramal, status) é clicável e alterna asc/desc (sortKey/sortDir em users-admin.js, ícone ↕ que fica --accent quando ativo) — mesmo padrão de #ips-list-table (importacao-plano-saude.js, ver portal_api/planos_saude/CLAUDE.md) e do modal de Ramais (ramais-lookup.js, ver docs/ramais/ramais.md), inclusive a mesma função de comparação (comparaValoresUsuario, número vs. número quando os dois convertem, senão localeCompare pt-BR) — cada arquivo mantém sua própria cópia da função, não foi extraída pra um utilitário compartilhado em api.js. "Perfil de Acesso" (junção de nomes) não é ordenável, mesmo critério das outras telas que não ordenam colunas agregadas.

Botão "Vincular" (visual, sem funcionalidade ainda) nos campos Código da Folha/Questor/Tareffa do formulário de edição: decisão explícita do usuário — esses 3 códigos vão futuramente ser buscados/vinculados a partir de uma ferramenta externa (ex.: codigo_tareffa via a view já existente em portal_api/database/ que lê o Tareffa, ver project_database_package na memória) em vez de digitados à mão, mas essa vinculação de verdade não foi implementada nesta rodada — só a estrutura visual. Cada um dos 3 campos (#ua-codigo-folha/#ua-codigo-questor/#ua-codigo-tareffa) ganhou um input + botão "Vincular" (ícone de elo + texto) encostados numa única caixa (.ua-field-link — borda/raio únicos, botão separado por border-left, mesmo estilo de referência que o usuário mostrou de um campo de busca com botão "Buscar" atado à direita); passou por duas versões mais simples antes (botão solto ao lado do input, depois só o ícone sem texto no canto) até o usuário pedir essa terceira, "no estilo do botão de buscar". Sempre disabled com title="Vinculação com sistema externo ainda não implementada" — não tem nenhum handler de clique em users-admin.js. codigo_contabit e ramal (também campos cadastrais na mesma seção "Dados Cadastrais") não ganharam o botão — não fazem parte do conjunto de códigos com vinculação externa planejada, continuam sendo só texto livre. Ao implementar a busca de verdade num momento futuro, reaproveitar esse mesmo botão (tirar o disabled, adicionar o handler), não recriar o campo do zero.

Liderança (gerente/coordenador) e o modal "Gerenciar Usuário"

Usuario.lideranca (booleano) marca um usuário como gerente/coordenador de outros; Usuario.liderados é um M2M auto-referenciado ("self", symmetrical=False, related_name="lideres") — ou seja, "A lidera B" não implica "B lidera A". Exemplo: marcar lideranca=True em "debora" e incluir "gabriel" em liderados representa "debora é gerente de gabriel".

Dois lugares gravam essa mesma relação:

  • Tela de Usuários (usuarios.html/users-admin.js, só quem tem gerencia_permissoes): seção "Liderança" no formulário de edição — checkbox "É gerente ou coordenador de outros usuários" (#ua-lideranca) libera (hidden) o widget de vinculação dual descrito abaixo (a própria conta sendo editada é excluída da lista de candidatos — mesmo padrão de guarda "frontend-only" já usado pra "não pode excluir a si mesmo"/"não pode se auto-desativar", não há checagem equivalente no backend). Salvar envia lideranca+liderados (array de ids) no mesmo payload de PATCH/POST /api/usuarios/.
  • Modal "Gerenciar Usuário" (account.js, disponível em todo shell via o item "Gerenciar Usuário" no dropdown da conta — substituiu o antigo botão direto "Alterar senha"): abre #manage-account-modal, que sempre tem um botão "Alterar senha" (que fecha esse modal e abre o #password-modal já existente, mesmo fluxo de antes) e, só se me.lideranca for true, o mesmo widget de vinculação dual — permitindo que o próprio gerente/coordenador se autogerencie sem precisar de acesso à tela administrativa de Usuários. Essa lista vem de GET /api/usuarios-resumo/ (não de /api/usuarios/, que exige gerencia_permissoes) e salvar dispara PATCH /api/me/liderados/, que grava na mesma Usuario.liderados — meus_liderados_view recusa (403) se request.user.lideranca for False, já que só faz sentido pra quem tem o checkbox marcado.

Widget "Usuários sob liderança" — duas tabelas, não vinculados à esquerda e vinculados à direita (.dual-select, static/js/dual-select.js + estilos em components.css): substituiu o antigo .checklist-box de uma lista só (checkbox + busca + "marcar todos") — reestruturado a pedido do usuário no mesmo estilo do widget "Usuários do Escritório" de Perfis de Acesso (ver acima), só que genérico o bastante pra rodar tanto em usuarios.html quanto dentro do modal "Gerenciar Usuário" (presente em todo shell). pidCriarSeletorDuplo(config) (dual-select.js, incluído no prefixo de scripts de todo shell, logo depois de api.js) é a fábrica compartilhada — recebe as referências de DOM de cada painel (available/linked: checkbox "selecionar todos", cabeçalho ordenável, dois campos de busca, corpo da tabela, rodapé de contagem e o botão de ação) mais secundariaValor(candidato) (aqui, departamentosTexto(), unindo os nomes dos departamentos por vírgula) e devolve { setDados(candidatos, vinculadosIniciais), getVinculadosIds() }. Cada tela (users-admin.js/account.js) só chama setDados() ao abrir o formulário/modal e getVinculadosIds() no momento de salvar — a vinculação em si é só em memória dentro do widget (nenhuma chamada de API própria), o "Vincular"/"Desvincular" só move ids entre os dois painéis local mente, igual ao checklist antigo (que também só populava um Set em memória até o "Salvar" de fora).

  • Cada painel tem: checkbox de seleção múltipla + "selecionar todos" (sobre as linhas filtradas visíveis, mesmo critério do antigo .checklist-select-all), coluna "Nome" ordenável (clique alterna asc/desc) e coluna "Departamento", com uma segunda linha de cabeçalho só com os dois campos de busca (por nome e por departamento, independentes) — filtro client-side sobre o array já carregado. O botão de ação do painel ("Vincular" a esquerda/"Desvincular" a direita) fica desabilitado enquanto a seleção daquele painel estiver vazia, e mover usuários limpa a seleção e re-renderiza os dois painéis (quem saiu de um painel aparece no outro).
  • #manage-account-modal-card (id novo no .modal-card do modal "Gerenciar Usuário") ganha a classe .modal-card--wide via JS (account.js) só quando me.lideranca é true — o modal volta ao tamanho padrão (420px) quando só tem o botão "Alterar senha", em vez de ficar largo à toa pra quem não lidera ninguém.
  • .dual-select/.dual-select__* moram em components.css (não numa folha de estilo dedicada), pela mesma razão de .checklist-box — o modal "Gerenciar Usuário" existe em todo shell, e a maioria deles não carrega perfis-acesso.css.
  • Usuário inativo nunca aparece nos candidatos — mesma decisão de "Usuários do Escritório" acima. Em usuarios.html, fillLideradosChecklist() (users-admin.js) filtra users por is_active antes de montar a lista de candidatos; no modal "Gerenciar Usuário", isso já vem de graça porque GET /api/usuarios-resumo/ (usuarios_resumo_view) só devolve usuários ativos.

seed_portal.py não reseta nome de um perfil já existente

Bug real corrigido, motivado pelo usuário ter renomeado o perfil "Integração e Inovação" código 8 pra "Inovação" — e criado um perfil novo "Integração", código 9: update_or_create(codigo=..., defaults={"nome": ..., ...}) reescrevia nome a cada execução, revertendo qualquer renomeação feita depois pela tela de Perfis de Acesso. Trocado por get_or_create(codigo=..., defaults={"nome": ...}) (nome só gravado na criação) + atribuição direta de ativo/gerencia_permissoes/permissoes a cada execução (esses continuam sendo realinhados sempre — é assim que o seed serve pra corrigir uma árvore de permissões corrompida/desatualizada, só nome parou de ser tocado). Mesmo espírito de "só inicializa na primeira criação" já usado pra gabriel/bruno. Ver [[feedback_seed_nao_reseta_gabriel_bruno]] na memória.

CSS

perfis-acesso.css (.pa-*) cobre as duas telas — inclui as seções (.ua-section*) e campos específicos (.ua-inline-add/.ua-departamento-item/.ua-active-toggle/.ua-liderados-field) do formulário de edição de usuarios.html, mesmo o arquivo se chamando "perfis-acesso".