Controle de Permissões de Acesso - SIGEEDU
Manual Técnico
Arquitetura, schema, controle de acesso e lógica de negócio deste painel, para quem for mantê-lo ou estendê-lo. Para o passo a passo de uso, veja o Manual do Usuário; para instalação/deploy, veja o README.md do projeto.
1. Arquitetura geral
Aplicação estática (HTML + JS vanilla + Tailwind via CDN), sem build step, seguindo o mesmo padrão dos demais projetos SIGEEDU em F:\CLAUDE (ex: remanejamento/). Toda a lógica de negócio e de acesso a dados vive no navegador, falando diretamente com o Supabase via supabase-js (chave pública anon); a segurança vem inteiramente de RLS + funções SECURITY DEFINER, nunca do front-end.
- Sem framework: DOM manipulado diretamente, sem virtual DOM/reatividade.
- Sem bundler: cada
<script>é um arquivo solto, sem transpilação. - Login e conexão com o Supabase são compartilhados entre projetos:
../login.htmle../config.jsficam na raiz do servidor (um nível acima da pasta deste projeto), usados também porremanejamento/,main-relatorios/etc.
2. Estrutura de arquivos
index.html - painel administrativo (exige login + permissão no módulo)
js/auth-guard.js - protege index.html (SSO, permissão por módulo, expiração de sessão)
js/gestao.js - lógica inteira da tela (5 abas + modais + upload de foto)
sql/001_migration.sql - schema-adjacent: tabelas já existiam; cria RPCs
iniciais e (histórico) políticas hoje removidas
sql/002_correcoes.sql - correção de timeout em auth.users + bucket "fotos"
sql/003_ajuste_rls.sql- remove a função/policies próprias do 001, migra
as RPCs para verifica_permissao_modulo
sql/004_relatorio_view.sql - view usada pelo botão "Relatórios" (ESTADO ATUAL)
README.md - passo a passo de instalação/deploy
MANUAL_USUARIO.html - guia de uso para administradores do painel
MANUAL_TECNICO.html - este arquivo
Os quatro arquivos SQL são migrações sequenciais, não intercambiáveis - representam o histórico real do que foi executado no banco, nesta ordem: 001 → 002 → 003 → 004. Não edite 001/002/003 retroativamente; correções viram um novo arquivo (005_...sql e assim por diante).
3. Modelo de dados
Tabelas que já existiam no banco (este projeto não as cria, só as usa/estende):
| Tabela | Papel neste projeto |
|---|---|
| public.main_permissao_usuarios | Tabela central: liga user_id + modulo + nivel_permissao (1-5) + locais (array de ids de instituicao, opcional) |
| public.main_modulos | Catálogo de módulos do SIGEEDU (modulo text, PK lógica única; descricao; id_documento_tipo opcional) |
| public.documento_tipo | Catálogo de tipos de documento (tipo, sigla, sigla_main) |
| public.permissao_usuarios | Rótulos dos níveis de permissão (id 1-5 tem significado fixo no código, ver seção 9.2 do modelo) |
| public.usuarios_cadastrados | Perfil do usuário (PK = user_id, FK para auth.users); usada por muitos outros módulos do SIGEEDU (dezenas de colunas permissao_* legadas, ignoradas por este projeto) |
| auth.users / auth.identities | Schema interno de autenticação do Supabase/GoTrue - nunca exposto via PostgREST, só via RPC SECURITY DEFINER |
| public.view_funiat | View de RH (funcionários) - fonte de auto-preenchimento do cadastro |
| public.cargo, public.instituicao | Catálogos usados nos comboboxes de Função/Unidade e na composição da "Lotação" |
| storage.buckets / storage.objects | Bucket fotos (criado por 002_correcoes.sql), pasta foto_funcionario/ |
| public.view_permus_permissoes | View criada por 004_relatorio_view.sql, usada só pelo botão "Relatórios" (SSO para main-relatorios) - uma linha por permissão concedida, já com nome/e-mail do usuário, descrição do módulo, rótulo do nível e nomes dos locais resolvidos |
Não há migrations de CREATE TABLE neste projeto para as tabelas acima - elas foram fornecidas prontas. 003_ajuste_rls.sql adiciona uma constraint nova (única) em main_permissao_usuarios (user_id, modulo) para permitir upsert seguro no lote de concessão (seção 6).
4. Autenticação e SSO
Segue o mesmo padrão corrigido de kimape-modo-kiosk (referência atual para todos os módulos SIGEEDU - mais completo que o padrão antigo de remanejamento/, que não conferia permissão por módulo). Resumo:
js/auth-guard.jsesconde a página, verifica sessão viasupabase.auth.getSession(), e redireciona para../login.html?pagina=permus.ase não houver sessão válida.- Aceita SSO por
?refresh_token=...&access_token=...na URL (aplica a sessão e limpa a URL imediatamente). ../login.htmltem uma entrada'permus.a'(e também'permus.relatorios') no mapaPAGINASapontando para./permus-permissoes-acesso-sigeedu/index.html, com o módulo derivado (permus) verificado contramain_permissao_usuariosno momento do login (mesmo mecanismo usado por todos os módulos).
4.1 Permissão por módulo verificada pelo próprio guard (não só no login)
Correção de segurança importante: a sessão do Supabase é compartilhada entre todos os módulos do SIGEEDU (mesmo navegador/origem). A versão anterior deste auth-guard.js só conferia se havia uma sessão válida, não se o usuário tinha permissão para este módulo especificamente - então alguém logado em qualquer outro sistema (ex: kimape) conseguia abrir este painel diretamente pela URL, mesmo sem nenhuma permissão `permus` concedida. Corrigido: o guard agora consulta main_permissao_usuarios (modulo = 'permus') antes de liberar a tela, exatamente como login.html já fazia no momento do login.
conferirPermissao(user) devolve 'ok' (nível 1-4), 'negada' (sem registro, nível vazio ou 5) ou 'erro' (falha de conexão/consulta - por segurança, a página não é liberada nesse caso, só mostra "Tentar novamente"). Quando negada, uma tela de bloqueio em tela cheia (HTML/CSS inline, não depende de nada da página) aparece com dois botões: "Entrar com outro usuário" (logout local + volta ao login deste módulo) e "Sair". A permissão é reconferida a cada 5 minutos enquanto a página está aberta (RECONFERIR_PERMISSAO_A_CADA_MS), para o caso de alguém revogar o acesso com a pessoa já usando o painel.
A função guarda o nível encontrado em window.__nivelPermissaoPagina - gestao.js reaproveita esse valor (via exibirNivelUsuario()) para mostrar "Nível N - Rótulo" embaixo do e-mail no rodapé do menu lateral, sem precisar repetir a mesma consulta.
4.2 Expiração de sessão
O token de acesso dura pouco e o cliente Supabase o renova sozinho com o refresh token. Quando a renovação não é mais possível (sessão vencida/revogada no servidor), o guard limpa a sessão local e redireciona para ../login.html?pagina=permus.a&motivo=expirou (que mostra o aviso). Isso é verificado em três momentos:
- ao abrir a página (havia uma sessão guardada no
localStorage, mas ela não pôde ser renovada); - durante o uso: a cada minuto (
VERIFICAR_SESSAO_A_CADA_MS), quando o Supabase emiteSIGNED_OUT, ao voltar o foco da aba/janela, e ao voltar a conexão (eventoonline); - falha de rede não é tratada como expiração (
ehErroDeRede()filtra isso) - a pessoa continua na tela e tenta de novo no próximo ciclo.
5. Controle de acesso (RLS)
Regra do sistema (válida para todo o SIGEEDU, não só este projeto): o único primitivo de controle de acesso é public.verifica_permissao_modulo(p_modulo text, p_id_local bigint, p_operacao text) (função pré-existente, SECURITY DEFINER, já usada por todos os outros módulos). Nunca criar função de permissão própria por módulo.
-- assinatura (já existia antes deste projeto): verifica_permissao_modulo(p_modulo text, p_id_local bigint, p_operacao text) returns boolean
Semântica por nivel_permissao em main_permissao_usuarios (fixa no código da função, independe do rótulo em permissao_usuarios):
| nível | select | insert/update | delete | ignora locais |
|---|---|---|---|---|
| 1 | sim | não | não | não |
| 2 | sim | sim | não | não |
| 3 | sim | sim | sim | não |
| 4 (Full) | sim | sim | sim | sim |
| 5 | não | não | não | - (bloqueia tudo) |
Para níveis 1-3, a função exige p_id_local = ANY(locais) - ou seja, locais vazio/nulo bloqueia acesso a qualquer local para esses níveis, a menos que o chamador passe um p_id_local que o próprio módulo trate como "não aplicável" (ver convenção abaixo).
5.1 Convenção p_id_local = 0 para módulos sem conceito de local
Este painel (permus) não é escopado por unidade escolar - toda permissão dele é "tudo ou nada" por usuário. Seguindo a convenção já usada em outros módulos sem local (ex: gdrive_grupos), todas as chamadas a verifica_permissao_modulo neste projeto passam 0 como p_id_local:
verifica_permissao_modulo('permus', 0, 'select')
verifica_permissao_modulo('permus', 0, 'insert')
verifica_permissao_modulo('permus', 0, 'update')
verifica_permissao_modulo('permus', 0, 'delete')
Isso significa que, para um usuário ter qualquer acesso de escrita/leitura restrito (níveis 1-3) neste painel, a linha dele em main_permissao_usuarios (modulo = 'permus') precisa ter 0 dentro do array locais (ex: locais = ARRAY[0]) - ou ter nível 4 (Full, que ignora locais de qualquer forma). O manual de instalação (README.md) já orienta cadastrar o primeiro administrador com nível 4 para evitar essa pegadinha.
5.2 Quem cria as policies
As policies RLS não são criadas pelos arquivos de migração deste projeto. Convenção do sistema inteiro (F:\CLAUDE): o próprio usuário cria/edita cada CREATE POLICY manualmente no editor de políticas do painel do Supabase, sempre chamando verifica_permissao_modulo. Os arquivos SQL só podem:
alter table ... enable row level security(infraestrutura);drop policy if exists ...(limpeza de policies antigas/erradas).
As sugestões de SQL para cada tabela (para colar manualmente no editor) estão na seção "Políticas RLS" do README.md.
Nota histórica: 001_migration.sql originalmente criava uma função própria (verifica_permissao_permus) e CREATE POLICY diretamente no arquivo - um desvio da convenção do sistema, corrigido por 003_ajuste_rls.sql (remove a função e as policies criadas por engano; a partir daí, tudo passa por verifica_permissao_modulo e as policies voltam a ser gerenciadas manualmente). Isso está documentado aqui para quem for ler o histórico dos arquivos 001/002 e estranhar a função verifica_permissao_permus mencionada neles - ela não existe mais no banco depois de rodar o 003.
5.3 usuarios_cadastrados: cuidado extra
Tabela compartilhada por muitos módulos do SIGEEDU (a lista de colunas permissao_* legadas nela é prova disso). 001_migration.sql propositalmente não ativa RLS nela (linha comentada) - habilitar RLS pela primeira vez numa tabela sem nenhuma policy previamente existente bloqueia todo acesso até que policies sejam criadas, o que pode quebrar telas de outros módulos que hoje dependem do acesso aberto padrão do Postgres/Supabase (RLS desativado = sem restrição). Antes de ativar, rode:
select relrowsecurity from pg_class where relname = 'usuarios_cadastrados';
Se já vier true, é seguro ativar policies aditivas a qualquer momento.
6. Funções RPC
Todas SECURITY DEFINER, com REVOKE ALL FROM PUBLIC + GRANT EXECUTE TO authenticated, e conferindo verifica_permissao_modulo('permus', 0, operação) internamente antes de qualquer efeito - a segurança não depende de quem descobre o nome da função, só de quem tem a permissão concedida.
| Função | Parâmetros | Operação | O que faz |
|---|---|---|---|
| main_permus_listar_auth_users() | - | select | Lê auth.users (nunca exposto via PostgREST) e devolve id, email, created_at, last_sign_in_at, email_confirmed_at, banned_until |
| main_permus_criar_auth_user(p_email, p_senha, p_confirmar) | e-mail, senha, confirmar automaticamente | insert | Insere diretamente em auth.users + auth.identities (ver 6.1) |
| main_permus_redefinir_senha(p_user_id, p_nova_senha) | uuid, nova senha | update | UPDATE auth.users SET encrypted_password = crypt(...) |
| main_permus_banir_auth_user(p_user_id, p_banir) | uuid, true/false | update | UPDATE auth.users SET banned_until = ... |
| main_permus_excluir_auth_user(p_user_id) | uuid | delete | Apaga main_permissao_usuarios do usuário, depois auth.users (cascata para usuarios_cadastrados e auth.identities) |
| main_permus_conceder_lote(p_user_ids, p_modulo, p_nivel_permissao, p_locais) | array de uuids, módulo, nível, locais | insert | Upsert manual (update; se não encontrar linha, insert) por usuário |
| main_permus_revogar_lote(p_ids) | array de bigint (ids de main_permissao_usuarios) | delete | DELETE ... WHERE id = ANY(p_ids) |
6.1 Sobre as funções que tocam auth.users
Técnica usada em instâncias self-hosted do Supabase sem Edge Functions: como não há como usar a Admin API do GoTrue (que exige a service_role key, que nunca deve ir para o front-end) com segurança direto do navegador, as funções inserem/atualizam diretamente nas tabelas internas auth.users/auth.identities, dentro do próprio Postgres (mesma técnica documentada em comunidades Supabase self-hosted).
Riscos e cuidados:
- O
INSERTfoi escrito para o layout padrão dessas tabelas numa versão comum de GoTrue; se a instância tiver colunas obrigatórias diferentes, o erro do Postgres aponta exatamente qual coluna falta - ajuste oINSERTe rodeCREATE OR REPLACE FUNCTIONde novo. - As funções de criação/senha/ban/exclusão têm
set local statement_timeout = '30s'no início - o padrão da API (~8s) não é suficiente para umDELETE/UPDATEemauth.usersnum banco com muitas tabelas referenciandoauth.users(id)sem índice nessas colunas (ver seção 12, "erro 500 / query_canceled"). main_permus_excluir_auth_userapaga primeiromain_permissao_usuariosdo usuário (a FK paraauth.usersnão temON DELETE CASCADE) antes de apagarauth.users- sem isso, a exclusão falharia por violação de FK sempre que o usuário tivesse alguma permissão concedida.
7. Frontend: padrões reutilizáveis
Todo em js/gestao.js, sem módulos ES/import - um único arquivo, organizado em blocos comentados. Padrões relevantes para quem for mexer:
7.1 Combobox pesquisável (initCombobox)
Substitui <select> simples nos campos que pedem busca (Usuário, Módulo, Conta vinculada, Função, Unidade de trabalho). Um input de texto + div de dropdown + (opcional) input hidden para o valor real:
initCombobox({
inputId, hiddenId, dropdownId, // ids dos elementos no HTML
getItens, // () => array atual (lido ao vivo, sem cache)
getValue, getLabel, // como extrair valor/rótulo de cada item
renderItem, // opcional: HTML customizado por item
onSelect, onManualChange // callbacks
})
Retorna { setValue(v), clear() } para controlar o campo ao abrir um modal de edição. Quando hiddenId é omitido (caso de "Função", que é texto livre), o próprio input de busca guarda o valor final.
7.2 Grade de "Locais" pesquisável
Substituiu o antigo <select multiple>. Um Set() em memória (permissaoLocaisSelecionados / loteLocaisSelecionados) guarda os ids marcados; renderLocaisGrid(gridId, selecionados, termoBusca) redesenha os cartões (destacando os marcados) a cada mudança - sem framework de estado, só um Set + re-render manual.
7.3 Modais e toasts
abrirModal(id)/fecharModal(id) (toggle de .hidden); qualquer botão com class="modal-fechar" data-modal="idDoModal" fecha o modal correspondente (delegação de evento única, no topo do arquivo). mostrarToast(texto, tipo) cria uma notificação flutuante temporária; mostrarMensagem(elemento, texto, tipo) preenche a faixa de erro/sucesso fixa dentro de um formulário.
7.4 Recarregamento de dados
Não há cache incremental: qualquer criação/edição/exclusão chama await carregarTudo() no final, que busca as 7 fontes de dados de novo (Promise.all) e re-renderiza as 6 telas (renderAll()). Simples e robusto para o volume de dados deste painel; não otimize prematuramente isso sem necessidade real.
7.5 Botão "Relatórios" (SSO para main-relatorios)
Mesmo padrão de kimape-modo-kiosk/funiat-gestao: abrirRelatorios() em gestao.js pega a sessão atual (supabase.auth.getSession()) e abre ../main-relatorios/index.html em outra aba, já autenticado, passando pagina=permus.relatorios + refresh_token + access_token na querystring.
O relatório em si não tem código próprio - main-relatorios/index.html é genérico: lê ?pagina=, procura a entrada correspondente no objeto RELATORIOS (dentro do próprio arquivo) e monta a tela de colunas/filtros/exportação a partir dali. A entrada 'permus.relatorios' aponta para a view public.view_permus_permissoes (criada por sql/004_relatorio_view.sql - ver seção 3). O próprio js/auth-guard.js de main-relatorios deriva o módulo a conferir a partir do texto antes do ponto em pagina ('permus.relatorios'.split('.')[0] = 'permus'), então a permissão exigida lá é automaticamente a mesma deste painel - não precisa de nenhuma permissão extra.
8. Recorte automático de foto de perfil
Implementado inteiramente em Canvas 2D, sem nenhuma biblioteca externa, em duas camadas (a primeira que funcionar é usada; se nenhuma funcionar, cai para o recorte manual neutro sem quebrar a tela):
8.1 Camada 1: FaceDetector (Shape Detection API)
API experimental do navegador (hoje só alguns Chromium). Quando disponível, usa boundingBox para centralizar e, se o navegador também devolver landmarks com pontos de olho (type contendo "eye"), calcula o ângulo exato da reta entre os dois olhos e usa isso como correção fina (técnica clássica de alinhamento facial). Tudo dentro de try/catch - qualquer falha cai silenciosamente para a camada 2.
8.2 Camada 2: detecção por região de conteúdo (PCA + componente conectado)
Função detectarRegiaoDeConteudo(img), roda numa cópia reduzida da imagem (máx. 240px no maior lado, para performance):
- Estima o fundo pela média dos pixels da borda da imagem.
- Binariza por distância de cor ao fundo estimado (limiar 38) - gera uma máscara "primeiro plano" vs "fundo".
- Isola o maior componente conectado (busca em largura, 4-vizinhos) dessa máscara - separa a foto real de poeira/ruído de scanner espalhado pela página (cada speck vira um componente isolado e pequeno; a foto é um bloco sólido bem maior). Essencial para funcionar com uma foto pequena dentro de uma página digitalizada maior, não só uma foto que ocupa o quadro inteiro.
- PCA (autovalores/autovetores 2x2 em forma fechada) sobre os pixels desse componente, para achar o ângulo do eixo mais alongado - assume-se retrato (mais alto que largo), então esse eixo deveria ficar vertical.
- Normalização de ângulo em duas etapas (ponto crítico, ver "Armadilhas" abaixo): primeiro reduz o ângulo PCA para
(-90°, 90°](uma reta e sua oposta representam o mesmo eixo), só então arredonda para o múltiplo de 90° mais próximo, separando emrotacaoBase (0/90/180/270)+anguloFino (residual dentro de ±45°). - Detecção de "de cabeça para baixo": compara a largura nos 30% mais extremos do topo vs. da base (no referencial já alinhado pelo passo 5) - ombros (mais largos) devem ficar embaixo; se o topo for nitidamente mais largo (>15%), soma 180° a
rotacaoBase. - Devolve
rotacaoBase,anguloFino, centro e tamanho do bloco detectado (em pixels da imagem original) - usados para já deixar zoom/posição/ângulo ajustados automaticamente.
Armadilhas já resolvidas aqui (documentadas porque não são óbvias e foram encontradas por teste extensivo, não por leitura de código):
- Ambiguidade de sinal do
atan2:0.5 * atan2(2*sxy, sxx-syy)pode devolver um ângulo ou seu suplementar por ruído numérico infinitesimal (sxyperto de zero), fazendo a correção "vazar" 180° de forma arbitrária caso você arredonde para o múltiplo de 90° mais próximo antes de normalizar para(-90°,90°]. Sempre normalize primeiro. - Divisão topo/base pelo ponto médio geométrico é frágil: um bloco assimétrico (cabeça estreita + ombros largos) desloca o centroide para o lado mais "pesado", então
(minY+maxY)/2não cai exatamente na fronteira real entre as duas partes. Usar os 30% mais extremos de cada ponta evita esse viés. - Offset deve ser aplicado no espaço LOCAL da imagem, não no espaço do canvas: se o deslocamento de centralização for aplicado via
ctx.translate()antes dectx.rotate(), girar depois desloca tudo (o pivô da rotação passa a ser o ponto deslocado, não o centro do canvas). A solução usada aqui é armazenaroffsetImgX/offsetImgYem pixels da própria imagem (não do canvas) e aplicá-los dentro dectx.drawImage(img, -w/2 + offsetImgX, -h/2 + offsetImgY), depois detranslate(centroDoCanvas) → rotate → scale- assim o deslocamento gira e escala junto com a imagem automaticamente, e girar/dar zoom depois nunca desalinha o que já estava centralizado. O arraste do mouse (em pixels de tela) é convertido para esse mesmo espaço local desfazendo a rotação/escala atual antes de somar.
A suíte de validação usada durante o desenvolvimento (26 cenários sintéticos cobrindo ângulos de 0° a 350° combinados com orientação normal/invertida) não faz parte do repositório - foi rodada localmente via Node para validar a matemática antes de aplicar ao gestao.js; se for alterar esta lógica, recomenda-se recriar testes semelhantes antes de confiar em mudanças.
9. Regras de negócio específicas
9.1 Código automático do módulo
Ao escolher um "Tipo de documento vinculado" (tanto ao criar quanto ao editar um módulo), o código é lowercase(sigla_main + sigla) do tipo de documento (ex: sigla_main='PER', sigla='MUS' → permus). Sem tipo de documento, o código é digitado manualmente. main_modulos.descricao é NOT NULL no banco mas não tem mais campo próprio na tela - é preenchida automaticamente com o tipo do documento (ou o próprio código, se não houver tipo de documento).
9.2 Telefone, CPF, Portaria, Iniciais
- Telefone: só dígitos; se não começar com
55e tiver 10-11 dígitos (DDD+número sem código de país), o55é prefixado automaticamente (normalizarTelefone). - CPF: só dígitos, validado por dígito verificador real (
cpfValido, algoritmo padrão de checksum), não só formato/tamanho. - Portaria: máscara progressiva
0000/0000(mascararPortaria). - Iniciais: primeira letra de cada palavra do nome, ignorando
DE, DA, DO, DAS, DOS, E(calcularIniciais). - Lotação:
SIGLA_CARGO/SIGLA_UNIDADE, recalculada automaticamente sempre que Função ou Unidade de trabalho mudam (via combobox), editável manualmente depois.
9.3 Auto-preenchimento a partir de view_funiat.json_lotacoes
json_lotacoes é uma coluna que guarda uma lista JSON como texto (precisa de JSON.parse, não vem como objeto/array nativo do PostgREST - extrairLotacaoAtual trata os dois casos por segurança). Cada item tem cargo (id de public.cargo), local (id de public.instituicao), portaria e is_atual (boolean). A regra de escolha: filtra só is_atual = true; se houver mais de um, prefere o que tiver portaria preenchida; usa esse item para preencher Função/Unidade/Portaria/Lotação.
10. Suposições de schema não confirmadas
Estas colunas foram citadas pelo usuário mas não constavam nos CREATE TABLE/CREATE VIEW originalmente fornecidos - o código tenta usá-las e se degrada automaticamente (sem quebrar) se não existirem:
public.instituicao.sigla,public.cargo.sigla- usadas para montar a "Lotação" automática.fetchInstituicoes()/fetchCargos()tentam selecionar com essas colunas; se der erro, refazem a consulta sem elas (mapeandosigla: null) e seguem normalmente - a Lotação simplesmente não é sugerida automaticamente.public.view_funiat.foto_perfil- usada para pré-carregar a foto ao vincular um funcionário.buscarFuncionarios()tenta uma vez com essa coluna na lista deselect; se der erro, remove a coluna do conjunto usado (colunasFuncionarioBusca, variável de módulo) e nunca mais tenta na mesma sessão de página.
usuarios_cadastrados.user_iniciais, .tipo_user e .user_foto já existiam no CREATE TABLE original fornecido - usadas diretamente, sem fallback.
11. Como estender
11.1 Adicionar um novo módulo ao sistema de permissões
Não precisa mexer neste painel - basta cadastrar o módulo (aba Módulos, ou insert into main_modulos) e conceder permissões normalmente pela aba Permissões. Se o novo módulo tiver conceito de local, suas próprias policies (no projeto dele) devem chamar verifica_permissao_modulo('codigo_do_modulo', p_id_local, operacao) com o p_id_local real (id de instituicao); se não tiver, siga a convenção p_id_local = 0 (seção 5.1) e documente isso no README daquele projeto.
11.2 Adicionar uma nova aba/tela
Siga o padrão das abas existentes em index.html (tab-btn + tab-content + header-content) e em gestao.js (uma seção render<Nome>() + wiring de eventos própria, chamada dentro de renderAll()). Não introduza um framework de front-end - o padrão deliberado deste projeto (e dos demais em F:\CLAUDE) é HTML+JS puro.
11.3 Nova função RPC que grava em auth.users
Copie o padrão de main_permus_redefinir_senha/_banir_auth_user: SECURITY DEFINER, set local statement_timeout generoso (30s), checagem verifica_permissao_modulo('permus', 0, operacao) como primeira linha do corpo, REVOKE ALL FROM PUBLIC + GRANT EXECUTE TO authenticated depois de criada. Nunca exponha a service_role key no front-end como alternativa.
12. Troubleshooting
Erro 500 / proxy-status: error=57014 ao chamar uma função RPC
57014 é query_canceled do Postgres - a função foi cancelada por exceder o statement_timeout padrão da role authenticated (~8s no Supabase), não um erro de lógica. Acontece em operações que tocam auth.users, porque o Postgres precisa checar todas as tabelas que referenciam auth.users(id) (há muitas no SIGEEDU: colunas criadopor/editadopor em vários módulos) - se alguma não tiver índice nessa coluna, a checagem de FK vira varredura completa da tabela. Solução já aplicada nas funções deste projeto: set local statement_timeout = '30s' só dentro da função afetada (não mexe no limite padrão da role, que continua protegendo o resto do sistema). Se o erro voltar a aparecer em outra função nova, aplique o mesmo padrão nela.
Painel carrega, mas nenhuma tabela mostra dados (tudo vazio) depois de rodar 003_ajuste_rls.sql
Esperado: o 003 remove as policies antigas e RLS continua habilitado nas tabelas - RLS habilitado sem nenhuma policy nega tudo. Crie as policies manualmente no painel do Supabase (seção "Políticas RLS" do README.md) antes de usar o painel de novo.
"Não é possível concluir: existem registros vinculados a este item"
Erro 23503 (foreign_key_violation) traduzido pela função traduzirErro() no front-end - tentativa de excluir/renomear algo ainda referenciado por outra linha (ex: excluir um módulo com permissões concedidas, ou renomear o código de um módulo em uso). Resolva a dependência primeiro (revogue as permissões, ou aceite que o código não pode mudar).
Conta criada pelo painel não consegue fazer login
O INSERT em auth.users/auth.identities foi escrito para um layout comum de GoTrue self-hosted; se a instância usada tiver colunas obrigatórias adicionais (versões diferentes do GoTrue variam), o erro do Postgres na criação apontará a coluna faltante - ajuste o INSERT em main_permus_criar_auth_user e rode CREATE OR REPLACE FUNCTION. Teste sempre com uma conta de teste antes de confiar no fluxo em produção.
Combobox pesquisável não mostra nenhum resultado, mesmo com dados carregados
Confira se getItens() está lendo a variável global correta (elas são recarregadas inteiras a cada carregarTudo(), não incrementalmente) e se getLabel(item) não está retornando undefined para algum item (quebra o .toLowerCase() do filtro de busca).
Tela "Sem permissão" aparece para um usuário que deveria ter acesso
Confira em main_permissao_usuarios se existe uma linha para esse user_id com modulo = 'permus' e nivel_permissao diferente de null/5. Lembre que a permissão é reconferida a cada 5 minutos com a página já aberta (seção 4.1) - uma alteração recente pode levar até esse tempo para refletir numa aba já aberta (atualizar a página força a reconferência na hora). Erros de rede/consulta também bloqueiam a tela por segurança ("Não foi possível verificar sua permissão") - veja o console do navegador para a causa exata.