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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions