64 lines
20 KiB
Markdown
64 lines
20 KiB
Markdown
# 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.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".
|