Implandação da customização das permissões dos usuarios e das notificaçoes

This commit is contained in:
2026-08-26 17:24:25 -03:00
parent 808c2c7545
commit 5bb5d74e22
71 changed files with 2487 additions and 269 deletions

View File

@@ -1,7 +1,10 @@
# app/controllers/admin/auditoria_logs_controller.rb
class Admin::AuditoriaLogsController < ApplicationController
def index
authorize AuditoriaLog, policy_class: ApplicationPolicy
# Policy própria (AuditoriaLogPolicy). Com `policy_class: ApplicationPolicy`
# a régua era a de consolidação: quem podia consolidar abria a auditoria pela
# URL, mesmo sem o item no menu.
authorize AuditoriaLog
@logs = AuditoriaLog.recentes.limit(200)
@logs = @logs.por_user(params[:user_id]) if params[:user_id].present?

View File

@@ -36,7 +36,7 @@ class Admin::MensagemTemplatesController < ApplicationController
assunto: params[:assunto], blocos: blocos_do_form, ativo: true)
provisorio.validate # dispara a limpeza dos blocos antes de renderizar
dados = Notificacao::Variaveis.amostra(@evento.gatilho)
dados = Notificacao::CatalogoVariaveis.amostra(@evento.gatilho)
render json: { html: corpo_do_preview(provisorio, dados),
assunto: provisorio.renderizador(dados).interpolar(provisorio.assunto) }
end

View File

@@ -0,0 +1,110 @@
# app/controllers/admin/perfis_acesso_controller.rb
#
# CRUD dos perfis de acesso. É aqui que o ADM decide o que cada função enxerga,
# sem depender de deploy: o catálogo do que pode ser marcado é código
# (Permissao::TODAS), a escolha é dado (PerfilAcesso#permissoes).
module Admin
class PerfisAcessoController < ApplicationController
include Auditavel
before_action :set_perfil, only: %i[edit update destroy]
def index
authorize PerfilAcesso
@perfis = policy_scope(PerfilAcesso).ordenados.includes(:users)
end
def new
@perfil = PerfilAcesso.new(ativo: true, permissoes: [])
authorize @perfil
end
def create
@perfil = PerfilAcesso.new(perfil_params)
authorize @perfil
if @perfil.save
auditar!(:criar, @perfil, dados_novos: { nome: @perfil.nome, permissoes: @perfil.permissoes })
redirect_to admin_perfis_path, notice: "Perfil #{@perfil.nome} criado."
else
render :new, status: :unprocessable_entity
end
end
def edit; end
def update
anteriores = @perfil.permissoes
# Ordem importa: aplicamos os valores em memória para a trava conferir o
# que o ADM ESTÁ salvando, não o que está no banco.
@perfil.assign_attributes(perfil_params)
if travaria_o_sistema?
@perfil.errors.add(:base, mensagem_da_trava)
return render(:edit, status: :unprocessable_entity)
end
if @perfil.save
auditar!(:editar, @perfil,
dados_anteriores: { permissoes: anteriores },
dados_novos: { permissoes: @perfil.permissoes, ativo: @perfil.ativo })
redirect_to admin_perfis_path, notice: "Perfil #{@perfil.nome} atualizado."
else
render :edit, status: :unprocessable_entity
end
end
def destroy
unless @perfil.apagavel?
return redirect_to(admin_perfis_path, alert: motivo_para_nao_apagar)
end
@perfil.destroy
auditar!(:excluir, @perfil, dados_anteriores: { nome: @perfil.nome })
redirect_to admin_perfis_path, notice: 'Perfil excluído.'
end
private
def set_perfil
@perfil = PerfilAcesso.find(params[:id])
authorize @perfil
end
def perfil_params
dados = params.require(:perfil_acesso).permit(:nome, :descricao, :ativo, permissoes: [])
# O form manda um campo vazio para o caso de NENHUMA caixa marcada (senão o
# parâmetro some e "desmarcar tudo" viraria "não mexer em nada").
dados[:permissoes] = Permissao.filtrar(dados[:permissoes])
dados
end
# ── Trava do último administrador ────────────────────────
# Mesma regra da tela de usuários (User.administradores_de_acesso): salvar um
# perfil que perde `admin.perfis`/`admin.usuarios_gerenciar` (ou é desativado)
# não pode deixar o sistema sem quem conserte.
def travaria_o_sistema?
return false if @perfil.administra_acesso? # continua podendo: nada a checar
return false unless @perfil.users.ativos.exists? # perfil vazio não tira acesso de ninguém
# Quem está NESTE perfil perderá o acesso; sobra alguém fora dele?
# Usuário sem perfil conta: cai no padrão do papel (admin legado administra).
restantes = User.administradores_de_acesso.reject { |u| u.perfil_acesso_id == @perfil.id }
restantes.empty?
end
def mensagem_da_trava
'Esta alteração deixaria o sistema sem nenhum usuário ativo capaz de gerenciar ' \
'perfis e usuários. Garanta que outra pessoa mantenha esse acesso antes de salvar.'
end
def motivo_para_nao_apagar
if @perfil.sistema?
'Perfis que vêm com o sistema não podem ser excluídos — desative-o ou edite as permissões.'
else
"Este perfil está em uso por #{@perfil.users.count} usuário(s). Mova essas pessoas para outro perfil antes de excluir."
end
end
end
end

View File

@@ -4,7 +4,7 @@ class Admin::UsuariosController < ApplicationController
def index
authorize User
@usuarios = policy_scope(User).order(:nome).limit(200)
@usuarios = policy_scope(User).includes(:perfil_acesso).order(:nome).limit(200)
@roles = User.roles.keys
end
@@ -36,6 +36,13 @@ class Admin::UsuariosController < ApplicationController
def update
authorize @usuario
# Trava do último administrador: mudar perfil ou desativar a única pessoa que
# ainda administra acesso deixaria o sistema sem quem conserte.
if desligaria_o_ultimo_administrador?
@usuario.errors.add(:base, MENSAGEM_ULTIMO_ADMIN)
return render(:edit, status: :unprocessable_entity)
end
if @usuario.update(usuario_params_update)
AuditoriaLog.registrar(user: current_user, acao: 'editar_usuario', entidade: 'User', dados_novos: { detalhes: "Editou usuário #{@usuario.email}" }, request: request)
redirect_to admin_usuarios_path, notice: 'Usuário atualizado.'
@@ -47,6 +54,10 @@ class Admin::UsuariosController < ApplicationController
def destroy
authorize @usuario
if User.ultimo_administrador?(@usuario)
return redirect_to(admin_usuarios_path, alert: MENSAGEM_ULTIMO_ADMIN)
end
if @usuario == current_user
redirect_to admin_usuarios_path, alert: 'Você não pode excluir sua própria conta.'
return
@@ -58,7 +69,11 @@ class Admin::UsuariosController < ApplicationController
end
def toggle_ativo
authorize @usuario, :update?
authorize @usuario, :toggle_ativo?
if @usuario.ativo? && User.ultimo_administrador?(@usuario)
return redirect_to(admin_usuarios_path, alert: MENSAGEM_ULTIMO_ADMIN)
end
@usuario.update!(ativo: !@usuario.ativo)
status = @usuario.ativo? ? 'ativado' : 'desativado'
AuditoriaLog.registrar(user: current_user, acao: "usuario_#{status}", entidade: 'User', dados_novos: { detalhes: "#{@usuario.email} foi #{status}" }, request: request)
@@ -67,18 +82,53 @@ class Admin::UsuariosController < ApplicationController
private
MENSAGEM_ULTIMO_ADMIN = 'Esta alteração deixaria o sistema sem nenhum usuário ativo capaz de ' \
'gerenciar perfis e usuários. Dê esse acesso a outra pessoa antes.'.freeze
def set_usuario
@usuario = User.find(params[:id])
end
# Aplica as mudanças em memória e pergunta: sobra alguém administrando?
def desligaria_o_ultimo_administrador?
simulado = @usuario.dup
simulado.id = @usuario.id
simulado.assign_attributes(usuario_params_update)
simulado.recarregar_permissoes!
return false if simulado.ativo? && simulado.pode?('admin.perfis') && simulado.pode?('admin.usuarios_gerenciar')
User.ultimo_administrador?(@usuario)
end
# ⚠️ CAMPOS DE ACESSO SÓ PARA QUEM PODE MEXER NELES.
# `UserPolicy#update?` libera editar a PRÓPRIA ficha (nome, senha) — é o que
# permite alguém trocar a própria senha. Sem o filtro abaixo, essa mesma
# permissão deixava qualquer usuário logado enviar `user[role]=admin` na edição
# do próprio cadastro e se promover. Quem decide papel e perfil é
# `UserPolicy#alterar_acesso?` (nunca sobre si mesmo).
CAMPOS_DE_ACESSO = %i[role perfil_acesso_id ativo].freeze
def usuario_params
params.require(:user).permit(:nome, :email, :password, :password_confirmation,
:role, :pin_code, :ativo)
permitidos = [:nome, :email, :password, :password_confirmation, :pin_code]
permitidos += CAMPOS_DE_ACESSO if pode_alterar_acesso?
params.require(:user).permit(permitidos)
end
def usuario_params_update
permitted = [:nome, :email, :role, :pin_code, :ativo]
permitted += [:password, :password_confirmation] if params[:user][:password].present?
params.require(:user).permit(permitted)
permitidos = [:nome, :email, :pin_code]
permitidos += CAMPOS_DE_ACESSO if pode_alterar_acesso?
permitidos += [:password, :password_confirmation] if params[:user][:password].present?
params.require(:user).permit(permitidos)
end
def pode_alterar_acesso?
if @usuario&.persisted?
policy(@usuario).alterar_acesso?
else
# Criação: não há "si mesmo" para se promover, basta poder gerenciar.
current_user.pode?('admin.usuarios_gerenciar')
end
end
helper_method :pode_alterar_acesso?
end

View File

@@ -0,0 +1,74 @@
# app/controllers/admin/variaveis_personalizadas_controller.rb
#
# CRUD das {{variaveis}} que o ADM cria. Uma vez cadastradas, aparecem no editor
# de mensagem ao lado das variáveis do sistema e valem para qualquer evento.
module Admin
class VariaveisPersonalizadasController < ApplicationController
include Auditavel
before_action :set_variavel, only: %i[edit update destroy]
def index
authorize VariavelPersonalizada
@variaveis = policy_scope(VariavelPersonalizada).ordenadas
# Mostrar o catálogo do sistema junto evita o erro mais comum: cadastrar
# {{data}} sem saber que já existe (o model recusa, mas melhor avisar antes).
@do_sistema = Notificacao::CatalogoVariaveis.por_origem.except(:personalizada)
end
def new
@variavel = VariavelPersonalizada.new(ativo: true)
authorize @variavel
end
def create
@variavel = VariavelPersonalizada.new(variavel_params)
authorize @variavel
if @variavel.save
auditar!(:criar, @variavel, dados_novos: { chave: @variavel.chave, valor: @variavel.valor })
redirect_to admin_variaveis_path, notice: "Variável #{@variavel.marcador} criada."
else
render :new, status: :unprocessable_entity
end
end
def edit; end
def update
anterior = @variavel.valor
if @variavel.update(variavel_params)
auditar!(:editar, @variavel,
dados_anteriores: { valor: anterior },
dados_novos: { valor: @variavel.valor })
redirect_to admin_variaveis_path, notice: "Variável #{@variavel.marcador} atualizada."
else
render :edit, status: :unprocessable_entity
end
end
def destroy
marcador = @variavel.marcador
@variavel.destroy
auditar!(:excluir, @variavel, dados_anteriores: { chave: @variavel.chave })
# A variável some, mas o texto que a usava continua lá: sem valor, o
# renderizador troca por vazio. Avisar é melhor do que a mensagem sair com
# um buraco que ninguém relaciona a esta exclusão.
redirect_to admin_variaveis_path,
alert: "Variável #{marcador} excluída. Mensagens que a usavam passam a mostrar espaço em branco no lugar."
end
private
def set_variavel
@variavel = VariavelPersonalizada.find(params[:id])
authorize @variavel
end
def variavel_params
params.require(:variavel_personalizada).permit(:chave, :valor, :descricao, :ativo)
end
end
end

View File

@@ -7,14 +7,31 @@ class ApplicationController < ActionController::Base
before_action :set_tema
before_action :preparar_splash
# Pundit: redireciona se não autorizado
rescue_from Pundit::NotAuthorizedError do |e|
flash[:alert] = "Você não tem permissão para realizar esta ação."
redirect_to root_path
# Pundit: redireciona se não autorizado.
#
# ⚠️ NÃO redirecione para `root_path` aqui: a raiz é o dashboard, e um perfil
# sem `dashboard.ver` entraria em loop (nega → raiz → nega). O destino é a
# primeira tela que a PESSOA pode abrir; quem não pode abrir nenhuma vai para a
# tela de sem-acesso, que explica a quem pedir.
rescue_from Pundit::NotAuthorizedError do |_e|
flash[:alert] = 'Você não tem permissão para acessar essa área.'
redirect_to destino_permitido
end
private
# Rota inicial deste usuário (ver User#home_rota). `sem_acesso_path` é o fim de
# linha: perfil sem nenhuma permissão.
def destino_permitido
rota = current_user&.home_rota
return sem_acesso_path if rota.nil?
# Evita loop quando a própria tela negada é a home calculada.
destino = public_send(rota)
destino == request.path ? sem_acesso_path : destino
end
helper_method :destino_permitido
def set_tema
@tema = current_user&.tema_preferido || 'dark'
end
@@ -28,8 +45,11 @@ class ApplicationController < ActionController::Base
@mostrar_splash = session.delete(:mostrar_splash)
end
# Mesma regra do Users::SessionsController: o destino é a primeira tela que o
# PERFIL da pessoa permite, não uma rota fixa por papel.
def after_sign_in_path_for(resource)
session[:mostrar_splash] = true
resource.motorista? ? motorista_dashboard_path : dashboard_path
rota = resource.home_rota
rota ? public_send(rota) : sem_acesso_path
end
end

View File

@@ -433,7 +433,7 @@ class ConsolidacaoEntregasController < ApplicationController
end
def bloquear_se_finalizada
return unless @consolidacao.finalizada? && current_user.operador?
return unless @consolidacao.finalizada? && !current_user.pode?('consolidacao.editar_finalizada')
redirect_to @consolidacao, alert: 'Consolidação finalizada — apenas admin/gerente podem editar.'
end

View File

@@ -66,7 +66,7 @@ class ConsolidacoesController < ApplicationController
# filtros da tela. Com ?detalhe=NOME sai só o extrato daquele motorista (com
# espaço de assinatura).
def totais_pdf
authorize Consolidacao, :index?
authorize Consolidacao, :exportar_pdf?
inicio, fim = periodo_totais
totais = Analytics::TotaisPorMotorista.new(
@@ -245,7 +245,7 @@ class ConsolidacoesController < ApplicationController
# POST /consolidacoes/:id/finalizar
def finalizar
authorize @consolidacao, :update?
authorize @consolidacao, :finalizar?
if @consolidacao.finalizar!(current_user)
auditar!(:finalizar, @consolidacao)
@@ -262,7 +262,7 @@ class ConsolidacoesController < ApplicationController
# não dá para consolidar (não 100% classificados) e finaliza a consolidação. Os
# veículos consolidados/prontos seguem no fechamento; os pendentes ficam de fora.
def finalizar_parcial
authorize @consolidacao, :update?
authorize @consolidacao, :finalizar_parcial?
arquivados = @consolidacao.arquivar_veiculos_pendentes!(current_user)
if @consolidacao.finalizar!(current_user)
@@ -278,7 +278,7 @@ class ConsolidacoesController < ApplicationController
# POST /consolidacoes/:id/arquivar
def arquivar
authorize @consolidacao, :destroy?
authorize @consolidacao, :arquivar?
@consolidacao.arquivar!(current_user)
auditar!(:arquivar, @consolidacao)
redirect_to consolidacoes_path, notice: 'Consolidação arquivada.'
@@ -292,7 +292,7 @@ class ConsolidacoesController < ApplicationController
# POST /consolidacoes/:id/reativar — restaura uma consolidação arquivada
def reativar
authorize @consolidacao, :destroy?
authorize @consolidacao, :reativar?
@consolidacao.reativar!(current_user)
auditar!(:reativar, @consolidacao)
redirect_to consolidacoes_path, notice: 'Consolidação reativada.'
@@ -350,7 +350,7 @@ class ConsolidacoesController < ApplicationController
# consolidação (reversível). Ele sai dos totais/pagamento/gate, mas os dados
# ficam guardados e podem ser restaurados. Não arquiva motorista já pago.
def arquivar_motorista
authorize @consolidacao, :update?
authorize @consolidacao, :gerir_motoristas?
cm = @consolidacao.consolidacao_motoristas.find(params[:consolidacao_motorista_id])
if cm.pago?
@@ -367,7 +367,7 @@ class ConsolidacoesController < ApplicationController
# POST /consolidacoes/:id/desarquivar_motorista — restaura um motorista arquivado.
def desarquivar_motorista
authorize @consolidacao, :update?
authorize @consolidacao, :gerir_motoristas?
cm = @consolidacao.consolidacao_motoristas.find(params[:consolidacao_motorista_id])
cm.desarquivar!(current_user)
@@ -382,7 +382,7 @@ class ConsolidacoesController < ApplicationController
# como consolidacao_entregas se liga ao motorista por STRING (motorista_nome,
# sem FK/cascade), apagamos os lançamentos dele à mão antes de remover o registro.
def excluir_motorista
authorize @consolidacao, :destroy?
authorize @consolidacao, :excluir_motorista?
cm = @consolidacao.consolidacao_motoristas.find(params[:consolidacao_motorista_id])
if cm.pago?
@@ -407,7 +407,7 @@ class ConsolidacoesController < ApplicationController
# consolidação (ou restaura, se estava arquivado). Útil para refazer rápido
# depois de excluir/arquivar por erro de precificação.
def adicionar_motorista
authorize @consolidacao, :update?
authorize @consolidacao, :gerir_motoristas?
nome = params[:motorista_nome].to_s.strip
if nome.blank?
@@ -435,7 +435,7 @@ class ConsolidacoesController < ApplicationController
# GET /consolidacoes/:id/gerar_pdf_relatorio?motorista=X
def gerar_pdf_relatorio
authorize @consolidacao, :show?
authorize @consolidacao, :exportar_pdf?
motorista = params[:motorista]
pdf = Pdf::RelatorioMotoristaPdf.new(consolidacao: @consolidacao, motorista: motorista)
@@ -449,7 +449,7 @@ class ConsolidacoesController < ApplicationController
# GET /consolidacoes/:id/gerar_pdf_financeiro — relatório financeiro da consolidação
def gerar_pdf_financeiro
authorize @consolidacao, :show?
authorize @consolidacao, :exportar_pdf?
pdf = Pdf::RelatorioFinanceiroConsolidacaoPdf.new(consolidacao: @consolidacao)
auditar!(:editar, @consolidacao, dados_novos: { pdf: 'financeiro' })

View File

@@ -3,11 +3,16 @@ class DashboardController < ApplicationController
MAX_DIAS_GRAFICO = 92 # limita a granularidade diária do gráfico (≈ 1 trimestre)
def index
skip_authorization
# Motorista tem painel próprio — não enxerga o dashboard administrativo
return redirect_to(motorista_dashboard_path) if current_user.motorista?
authorize :dashboard, :ver?
# Custo, pagamento e ticket médio são permissão à parte: um perfil de
# acompanhamento vê a operação sem ver quanto ela custa. Antes o controller
# usava skip_authorization e o papel `externo` enxergava tudo.
@ver_financeiro = current_user.pode?('dashboard.financeiro')
@periodo_inicio, @periodo_fim = periodo_selecionado
carregar_dados_dashboard
end
@@ -15,9 +20,11 @@ class DashboardController < ApplicationController
# GET /dashboard/relatorio_financeiro?inicio=&fim=&operacoes[]=
# Relatório financeiro do período (PDF) — mesmos números da tela.
def relatorio_financeiro
skip_authorization
return redirect_to(motorista_dashboard_path) if current_user.motorista?
authorize :dashboard, :financeiro?
@ver_financeiro = true
@periodo_inicio, @periodo_fim = periodo_selecionado
carregar_dados_dashboard
@@ -58,9 +65,10 @@ class DashboardController < ApplicationController
# PLANEJADA: é o único recorte sem checkout, então planned_date é o eixo
# possível. Mesmo recorte do card, para o total bater.
def pendentes
skip_authorization
return redirect_to(motorista_dashboard_path) if current_user.motorista?
authorize :dashboard, :pendentes?
@periodo_inicio, @periodo_fim = periodo_selecionado
@operacoes_agrupadas = Operacao.agrupadas_por_mes
@operacao_filtro = Operacao.sanitizar(params[:operacoes])
@@ -179,12 +187,13 @@ class DashboardController < ApplicationController
@consolidacoes_abertas = @consolidacoes_mes.where(status: :rascunho).count
@consolidacoes_fechadas = @consolidacoes_mes.where(status: :finalizada).count
# Pagamentos: pago vs pendente entre as consolidações FINALIZADAS do período
# (medido por motorista — fonte: consolidacao_motoristas).
carregar_dados_pagamentos
# Visão financeira por operação (KPIs + gráficos)
carregar_dados_financeiro
# Pagamentos e visão financeira só são consultados por quem pode vê-los: sem a
# permissão, o dado não chega à página (a view esconde os blocos, mas o certo
# é nem carregar — é consulta cara e é informação de custo).
if @ver_financeiro
carregar_dados_pagamentos
carregar_dados_financeiro
end
# Histórico estimado mais recente
@historico_recente = HistoricoEstimado.order(created_at: :desc).limit(5)

View File

@@ -82,7 +82,9 @@ class OperacoesDashboardController < ApplicationController
# Baixa a planilha Entregas (.xlsx) da operação já preenchida com o resultado
# do rastreio — o modelo que é entregue ao cliente.
def baixar_planilha
authorize :dashboard, :operacoes?
# Chave própria: aqui o dado do cliente SAI do sistema (.xlsx), o que é
# diferente de olhar os números na tela.
authorize :dashboard, :exportar?
tabela = params[:operacao].to_s
unless Operacao.valida?(tabela)

View File

@@ -0,0 +1,12 @@
# app/controllers/paginas_controller.rb
#
# Telas do sistema que não pertencem a nenhum recurso.
class PaginasController < ApplicationController
# Quem chega aqui está autenticado, mas o perfil de acesso não libera nenhuma
# tela. Sem esta página, o "acesso negado" mandaria de volta para a raiz — que
# também nega — e a pessoa ficaria num loop de redirecionamento.
def sem_acesso
skip_authorization
@perfil = current_user.perfil_acesso
end
end

View File

@@ -20,14 +20,13 @@ class Users::SessionsController < Devise::SessionsController
protected
# Para onde a pessoa vai depois de entrar. NÃO é mais "motorista → painel,
# resto → dashboard": com perfis de acesso, alguém pode não ter o dashboard e
# cairia direto numa tela negada. `home_rota` devolve a primeira tela permitida.
def after_sign_in_path_for(resource)
session[:mostrar_splash] = true # dispara a animação de abertura uma vez
case resource.role
when 'motorista'
motorista_dashboard_path
else
dashboard_path
end
rota = resource.home_rota
rota ? public_send(rota) : sem_acesso_path
end
def after_sign_out_path_for(resource_or_scope)