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 6 —
Admin::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
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: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)
bootstrap ~> 5.3.3simple_form ~> 5.0.2kaminari ~> 1.2.1devise ~> 4.9.1pundit ~> 2.1.0Gems to remove after migration:
activeadmin ~> 3.2,has_scope ~> 0.7.2,select2-rails ~> 4.0.13Architecture overview
File layout
Base controller
Reuses existing
superadmin?fromconfig/initializers/superadmins.rb.The internalised DSL — three parts
1. A concern for CSV import (
Admin::CsvImportable)Provides
upload_csv/import_csvactions, wired with one line:2. Inline filtering — plain ActiveRecord scopes +
params, no Ransack (keeps dependency count down):3. Shared partials as visual DSL — every index view reads the same way:
Plain ERB, no magic, fully grep-able.
Locality of behaviour — modern browser APIs first
Since we can target modern browsers:
<dialog>for confirmation modals — no Bootstrap modal JS neededpopover+popovertarget) for filter panels and action menus — zero JSBootstrap 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
indexwithout_membershipsscopeindex,destroy+ CSVseconds_to_hmformatted amountindex,destroyRoutes
Migration phases
BaseController, layout,admin.scss, routes skeletonAdmin::CsvImportableconcern + wire up existing importersactiveadmin,has_scope,select2-railsfrom Gemfile; deleteapp/admin/; clean up locale filesVerification
After each phase:
rails routes | grep admin— expected routes presentAfter Phase 7:
bundle exec rails assets:precompilesucceedsgrep -r 'active_admin\|ActiveAdmin' app/ config/returns nothing