Skip to content

Replace ActiveAdmin with custom admin controllers #837

@rewritten

Description

@rewritten

Context

Part of #835 (supply chain risk reduction). ActiveAdmin is a large, opaque framework that generates behaviour through a proprietary DSL — hard to audit, hard to extend outside its abstractions, and brings several transitive dependencies (activeadmin, has_scope, select2-rails). The goal is to replace it with a custom admin section that:

  • Lives entirely in the project (an internalised DSL)
  • Uses only gems already present (Bootstrap 5, simple_form, kaminari, devise, pundit)
  • Provides enough shared structure to avoid repetition across the 8 resources
  • Opens a clean path to progressive enhancement with Stimulus/Turbo Frames later

The approach: a namespaced controller hierarchy (Admin::) with shared partials as the visual building blocks, keeping all logic readable and grep-able without knowing a framework.


Existing gems that already cover all needs (nothing new required)

Need Gem Status
Styling bootstrap ~> 5.3.3 ✅ already in Gemfile
Forms simple_form ~> 5.0.2 ✅ already in Gemfile
Pagination kaminari ~> 1.2.1 ✅ already in Gemfile
Auth devise ~> 4.9.1 ✅ already in Gemfile
Authz pundit ~> 2.1.0 ✅ already in Gemfile
CSV import existing service objects ✅ already exist

Gems to remove after migration: activeadmin ~> 3.2, has_scope ~> 0.7.2, select2-rails ~> 4.0.13


Architecture overview

File layout

app/
  controllers/
    admin/
      base_controller.rb          # auth, layout, shared helpers
      concerns/
        csv_importable.rb         # shared upload_csv / import_csv actions
      dashboard_controller.rb
      users_controller.rb
      organizations_controller.rb
      posts_controller.rb
      categories_controller.rb
      transfers_controller.rb
      petitions_controller.rb
      documents_controller.rb
  views/
    admin/
      shared/
        _page_header.html.erb     # title + action buttons
        _filters.html.erb         # filter form wrapper (Bootstrap card)
        _flash.html.erb           # alert/notice banners
        _pagination.html.erb      # kaminari links
        _csv_upload_form.html.erb # shared CSV upload form
      layouts/admin.html.erb      # Bootstrap 5 navbar + main content
      dashboard/index.html.erb
      users/  organizations/  posts/  categories/  transfers/  petitions/  documents/
  assets/
    stylesheets/admin.scss        # brand colour + CSS View Transition rules

Base controller

class Admin::BaseController < ApplicationController
  layout 'admin'
  before_action :authenticate_superadmin!

  private

  def authenticate_superadmin!
    authenticate_user!
    redirect_to root_path, alert: t('not_authorized') unless current_user.superadmin?
  end
end

Reuses existing superadmin? from config/initializers/superadmins.rb.

The internalised DSL — three parts

1. A concern for CSV import (Admin::CsvImportable)
Provides upload_csv / import_csv actions, wired with one line:

class Admin::UsersController < Admin::BaseController
  include Admin::CsvImportable
  csv_importer UserImporter   # existing service object
end

2. Inline filtering — plain ActiveRecord scopes + params, no Ransack (keeps dependency count down):

def index
  scope = User.includes(:members)
  scope = scope.where('email LIKE ?', "%#{params[:email]}%") if params[:email].present?
  scope = scope.where(locale: params[:locale]) if params[:locale].present?
  @users = scope.order(created_at: :desc).page(params[:page])
end

3. Shared partials as visual DSL — every index view reads the same way:

<%= render 'admin/shared/page_header', title: 'Users', new_path: new_admin_user_path %>
<%= render 'admin/users/filters' %>
<table class="table table-sm table-hover"></table>
<%= render 'admin/shared/pagination', collection: @users %>

Plain ERB, no magic, fully grep-able.

Locality of behaviour — modern browser APIs first

Since we can target modern browsers:

  • Native <dialog> for confirmation modals — no Bootstrap modal JS needed
  • Native Popover API (popover + popovertarget) for filter panels and action menus — zero JS
  • CSS View Transitions for smooth page-to-page navigation — zero JS:
@view-transition { navigation: auto; }
::view-transition-old(root) { animation: 120ms ease-out fade-out; }
::view-transition-new(root) { animation: 120ms ease-in  fade-in;  }

Bootstrap 5 JS bundle can therefore be skipped for the admin layout; only Bootstrap CSS is needed.

A Stimulus layer (live filter debounce, in-place toggles, Turbo Frames around results) can be added as a follow-up without changing the architecture.


Resources to migrate

Resource Actions Notes
Dashboard index Stats queries, recent items
User full CRUD + CSV Nested memberships form, without_memberships scope
Organization full CRUD Logo upload (Active Storage), guarded destroy
Post full CRUD + CSV Offer/Inquiry subclass selector
Category full CRUD Icon name field, translations display
Transfer index, destroy + CSV seconds_to_hm formatted amount
Petition index, destroy Guarded destroy (cannot delete accepted petitions)
Document full CRUD Per-locale title + content fields

Routes

namespace :admin do
  root to: 'dashboard#index'
  resources :users do
    collection { get :upload_csv; post :import_csv }
  end
  resources :organizations
  resources :posts do
    collection { get :upload_csv; post :import_csv }
  end
  resources :categories
  resources :transfers, only: [:index, :destroy] do
    collection { get :upload_csv; post :import_csv }
  end
  resources :petitions, only: [:index, :destroy]
  resources :documents
end

Migration phases

  • Phase 1 — Infrastructure: BaseController, layout, admin.scss, routes skeleton
  • Phase 2 — Simple resources: Category, Document, Petition, Transfer
  • Phase 3 — Medium resources: Post (CSV + subclass), Organization (file upload, guarded destroy)
  • Phase 4 — Complex resource: User (nested memberships, scopes, batch destroy, CSV)
  • Phase 5 — Dashboard (stats + recent items)
  • Phase 6Admin::CsvImportable concern + wire up existing importers
  • Phase 7 — Remove activeadmin, has_scope, select2-rails from Gemfile; delete app/admin/; clean up locale files

Verification

After each phase:

  • rails routes | grep admin — expected routes present
  • Browser: superadmin can access, non-superadmin is redirected

After Phase 7:

  • bundle exec rails assets:precompile succeeds
  • Test suite passes
  • grep -r 'active_admin\|ActiveAdmin' app/ config/ returns nothing

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions