Class: Seams::Generators::AdminGenerator

Inherits:
Rails::Generators::Base
  • Object
show all
Includes:
EjectAware, HostInjector
Defined in:
lib/generators/seams/admin/admin_generator.rb

Overview

Generates a canonical Admin engine on top of the generic engine scaffold. Wave 11A — Phase 1 (foundation) + Phase 2 (dashboards). Phase 3 wires Pundit policies + the audit-log auto-write.

Phase 1 (foundation) ships:

- lib/admin/engine.rb that registers events, mounts under the
  host, and asserts Auth::Identity + Administrate are present
  at boot.
- lib/admin.rb + lib/admin/configuration.rb exposing four
  configuration knobs:
    - authenticator       (callable; default: staff? on current_identity)
    - tenancy_scope       (:platform | :tenant; default :platform)
    - theme_css_path      (nil; host-supplied admin restyle)
    - before_admin_action (callable; hook for 2FA / IP allow-list)
- app/controllers/seams/admin/application_controller.rb
  subclassing Administrate::ApplicationController, gating via
  the authenticator concern.
- lib/admin/concerns/authenticator.rb — the gate concern.
- config/routes.rb scoped to the engine, with insertion-point
  markers + the twelve canonical resource declarations.

Phase 2 (dashboards) ships:

- app/dashboards/admin/<name>_dashboard.rb — twelve Administrate
  dashboards covering Identity, Account, Membership (x2),
  Team, TeamMembership, Invitation, Notification,
  NotificationPreference, Plan, Subscription, Invoice,
  LifetimePass. Each subclasses Administrate::BaseDashboard.
- app/controllers/admin/<plural>_controller.rb — twelve
  thin Administrate controllers, each subclassing
  Seams::Admin::ApplicationController (NOT Administrate's
  directly) so they inherit the gate, the pundit_user hook,
  and Phase 3's audit-log auto-write.
- Dummy app schema + slim model stubs for each of the twelve
  so the engine's runtime spec can boot Administrate against
  a real ActiveRecord schema.

The engine ships NO migrations. It is read-only over existing tables; Phase 3 may revisit if the audit-log table needs an extra column.

Run with: bin/rails generate seams:admin rubocop:disable Metrics/ClassLength

Constant Summary collapse

ENGINE_NAME =
"admin"
DASHBOARD_MODELS =

Phase 2 dashboard catalogue. Each entry carries:

- the snake-cased dashboard name (filename + dashboard class)
- the full model class name (used inside the controller via
  `def resource_class`)
- the owning engine (informational; useful for documentation
  and future Pundit policy splits in Phase 3)

The order here is the order routes + dashboards appear in the generated files; Identity comes first so the engine root (‘root to: “admin/identities#index”`) lands on something meaningful.

[
  # [dashboard_basename, model_class, owning_engine]
  ["identity",                "Auth::Identity",                       "auth"],
  ["account",                 "Accounts::Account",                    "accounts"],
  ["accounts_membership",     "Accounts::Membership",                 "accounts"],
  ["team",                    "Teams::Team",                          "teams"],
  ["teams_membership",        "Teams::Membership",                    "teams"],
  ["invitation",              "Teams::Invitation",                    "teams"],
  ["notification",            "Notifications::Notification",          "notifications"],
  ["notification_preference", "Notifications::NotificationPreference", "notifications"],
  ["plan",                    "Billing::Plan",                        "billing"],
  ["subscription",            "Billing::Subscription",                "billing"],
  ["invoice",                 "Billing::Invoice",                     "billing"],
  ["lifetime_pass",           "Billing::LifetimePass",                "billing"]
].freeze

Constants included from EjectAware

EjectAware::EJECT_HEADER_PREFIX

Instance Method Summary collapse

Methods included from EjectAware

#ejected?, #template_unless_ejected

Methods included from HostInjector

#host_inject_gem, #host_inject_include_in_application_controller, #host_inject_include_in_user, #host_inject_mount, #host_uninject_gem, #host_uninject_include, #host_uninject_mount, #routes_draw_anchor

Instance Method Details

#append_administrate_to_engine_gemfileObject



243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
# File 'lib/generators/seams/admin/admin_generator.rb', line 243

def append_administrate_to_engine_gemfile
  # The engine's standalone Gemfile (engine_path("Gemfile"))
  # gets `administrate` appended so that running engine specs
  # in isolation (cd engines/admin && bundle exec rspec) can
  # require the gem. The host-side Gemfile is updated separately
  # by `wire_into_host`.
  gemfile = engine_path("Gemfile")
  return unless File.exist?(gemfile)

  contents = File.read(gemfile)
  return if contents.include?('gem "administrate"')

  File.write(gemfile, contents.rstrip + <<~RB)


    # Phase 2 dashboards subclass Administrate::BaseDashboard; the
    # gem must be available when the engine runs its own specs.
    gem "administrate", "~> 1.0"
  RB
end

#append_pundit_to_engine_gemfileObject

Phase 3 — append ‘pundit` to the engine’s standalone Gemfile so ‘cd engines/admin && bundle exec rspec` can require it. The host-side Gemfile already gets pundit via `wire_into_host` (added in Phase 2 ahead of Phase 3 wiring).



268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
# File 'lib/generators/seams/admin/admin_generator.rb', line 268

def append_pundit_to_engine_gemfile
  gemfile = engine_path("Gemfile")
  return unless File.exist?(gemfile)

  contents = File.read(gemfile)
  return if contents.include?('gem "pundit"')

  File.write(gemfile, contents.rstrip + <<~RB)


    # Phase 3 ApplicationController includes Pundit::Authorization
    # and the per-model policies live under Admin::Platform::*
    # and Admin::Tenant::*. The gem must be available when the
    # engine runs its own specs.
    gem "pundit", "~> 2.4"
  RB
end

#create_application_controllerObject



134
135
136
137
138
139
140
# File 'lib/generators/seams/admin/admin_generator.rb', line 134

def create_application_controller
  # Zeitwerk-friendly path: lives at app/controllers/seams/admin/
  # so the constant `Seams::Admin::ApplicationController` resolves
  # without explicit requires.
  template_unless_ejected "app/controllers/admin/application_controller.rb.tt",
                          engine_path("app/controllers/seams/admin/application_controller.rb")
end

#create_authenticator_concernObject



147
148
149
150
# File 'lib/generators/seams/admin/admin_generator.rb', line 147

def create_authenticator_concern
  template_unless_ejected "lib/concerns/authenticator.rb.tt",
                          engine_path("lib/admin/concerns/authenticator.rb")
end

#create_base_engineObject



90
91
92
# File 'lib/generators/seams/admin/admin_generator.rb', line 90

def create_base_engine
  EngineGenerator.start([ENGINE_NAME], destination_root: destination_root)
end

#create_configurationObject



142
143
144
145
# File 'lib/generators/seams/admin/admin_generator.rb', line 142

def create_configuration
  template_unless_ejected "lib/configuration.rb.tt",
                          engine_path("lib/admin/configuration.rb")
end

#create_contextObject

Phase 3 — Seams::Admin::Context Struct (the value pundit_user returns). Wraps the current Identity + Membership so policies can read both signals (staff? on Identity, role/account_id on Membership) without each policy reaching into the controller.



156
157
158
159
# File 'lib/generators/seams/admin/admin_generator.rb', line 156

def create_context
  template_unless_ejected "lib/context.rb.tt",
                          engine_path("lib/admin/context.rb")
end

#create_dashboardsObject

Phase 2: emit one dashboard + one controller per entry in DASHBOARD_MODELS. ‘template_unless_ejected` so a host that ejects an individual dashboard (e.g. to restyle the Identity form) keeps their version on the next generator run.



193
194
195
196
197
198
199
200
201
202
203
204
205
# File 'lib/generators/seams/admin/admin_generator.rb', line 193

def create_dashboards
  DASHBOARD_MODELS.each do |basename, _model_class, _engine|
    template_unless_ejected(
      "app/dashboards/admin/#{basename}_dashboard.rb.tt",
      engine_path("app/dashboards/admin/#{basename}_dashboard.rb")
    )

    template_unless_ejected(
      "app/controllers/admin/#{basename.pluralize}_controller.rb.tt",
      engine_path("app/controllers/admin/#{basename.pluralize}_controller.rb")
    )
  end
end

#create_dummy_appObject



207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
# File 'lib/generators/seams/admin/admin_generator.rb', line 207

def create_dummy_app
  # Phase 2 dashboards target every canonical seams model, so
  # the dummy schema covers every table they read. Slim model
  # stubs ship alongside so Administrate's Zeitwerk-driven class
  # resolution finds an `Auth::Identity` / `Billing::Plan` /
  # etc. constant when the dashboard class loads.
  Seams::Generators::DummyAppWriter.write!(
    engine_path: File.join(destination_root, "engines", ENGINE_NAME),
    engine_module: "Admin",
    mount_at: "/admin",
    schema: dummy_schema,
    host_user: dummy_host_identity,
    host_user_path: "app/models/auth/identity.rb"
  )
  write_auth_current_stub
  write_accounts_current_stub
  write_dummy_model_stubs
  amend_dummy_application_rb
  rewrite_dummy_routes_for_namespaced_engine
end

#create_factoriesObject



286
287
288
289
# File 'lib/generators/seams/admin/admin_generator.rb', line 286

def create_factories
  template_unless_ejected "spec/factories/admin.rb.tt",
                          engine_path("spec/factories/admin.rb")
end

#create_policiesObject



171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
# File 'lib/generators/seams/admin/admin_generator.rb', line 171

def create_policies
  POLICY_NAMESPACES.each do |namespace|
    template_unless_ejected(
      "app/policies/admin/#{namespace}/application_policy.rb.tt",
      engine_path("app/policies/admin/#{namespace}/application_policy.rb")
    )
  end

  DASHBOARD_MODELS.each do |basename, _model_class, _engine|
    POLICY_NAMESPACES.each do |namespace|
      template_unless_ejected(
        "app/policies/admin/#{namespace}/#{basename}_policy.rb.tt",
        engine_path("app/policies/admin/#{namespace}/#{basename}_policy.rb")
      )
    end
  end
end

#create_runtime_specsObject



297
298
299
300
# File 'lib/generators/seams/admin/admin_generator.rb', line 297

def create_runtime_specs
  template "spec/runtime/admin_boot_spec.rb.tt",
           engine_path("spec/runtime/admin_boot_spec.rb")
end

#create_unit_specsObject



291
292
293
294
295
# File 'lib/generators/seams/admin/admin_generator.rb', line 291

def create_unit_specs
  # Per-dashboard specs are deferred to Phase 3 (where the
  # Pundit policies introduce real behaviour worth covering).
  # Phase 2's coverage lives in the runtime boot spec.
end

#overwrite_engine_entry_pointObject



94
95
96
97
98
# File 'lib/generators/seams/admin/admin_generator.rb', line 94

def overwrite_engine_entry_point
  # engine.rb / lib/admin.rb stay framework-managed.
  template "lib/engine.rb.tt", engine_path("lib/admin/engine.rb"), force: true
  template "lib/admin.rb.tt",  engine_path("lib/admin.rb"),        force: true
end

#overwrite_readmeObject



302
303
304
# File 'lib/generators/seams/admin/admin_generator.rb', line 302

def overwrite_readme
  template "README.md.tt", engine_path("README.md"), force: true
end

#overwrite_routesObject



100
101
102
# File 'lib/generators/seams/admin/admin_generator.rb', line 100

def overwrite_routes
  template_unless_ejected "config/routes.rb.tt", engine_path("config/routes.rb"), force: true
end

#remove_single_namespace_leftoversObject

The base EngineGenerator creates app/controllers/admin/ and app/models/admin/ files because it assumes a single-segment namespace (‘Admin::*`). The admin engine uses the two-segment `Seams::Admin::*` namespace for its own ApplicationController, but the per-dashboard controllers + dashboards live under the single-segment `Admin::*` namespace (Administrate’s convention). We delete only the leftover ApplicationController / ApplicationRecord files; Phase 2 templates re-populate app/controllers/admin/.



113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
# File 'lib/generators/seams/admin/admin_generator.rb', line 113

def remove_single_namespace_leftovers
  %w[
    app/controllers/admin/application_controller.rb
    app/models/admin/application_record.rb
    spec/admin_spec.rb
  ].each do |relative|
    full = engine_path(relative)
    next unless File.exist?(full)

    FileUtils.rm(full)
    say "  remove  #{relative} (single-namespace leftover)", :red
  end

  # Best-effort: clean up the now-empty parent dir for app/models/admin.
  # (app/controllers/admin gets repopulated by Phase 2 dashboards.)
  full = engine_path("app/models/admin")
  return unless File.directory?(full) && Dir.empty?(full)

  Dir.rmdir(full)
end

#report_summaryObject



316
317
318
# File 'lib/generators/seams/admin/admin_generator.rb', line 316

def report_summary
  say report_summary_text, :green
end

#report_summary_textObject



320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
# File 'lib/generators/seams/admin/admin_generator.rb', line 320

def report_summary_text
  <<~TXT

    Admin engine generated at engines/admin/

    Next steps:
      1. bundle install
         (picks up administrate + pundit, both injected into the host Gemfile)

      2. Promote yourself to a platform admin:
           bin/rails runner 'Auth::Identity.find_by(email: "you@example.com").update!(staff: true)'
         No migration is needed — admin is read-only over the
         existing seams tables, and `staff` already lives on
         auth_identities (Wave 9).

      3. Boot the host:
           bin/rails server

      4. Visit /admin in your browser. You should land on the
         Identities index with sidebar entries for all twelve
         canonical seams models.

    Tenancy modes:
      - :platform (default) — admins see every Account's data.
        Gate: Auth::Identity#staff?.
      - :tenant — admins see only their own Account's data.
        Gate: Accounts::Membership#role == "admin".
      Switch via config/initializers/seams_admin.rb:
        Seams::Admin.configure { |c| c.tenancy_scope = :tenant }

    Customise a dashboard:
      bin/seams resolve --eject admin/app/dashboards/admin/identity_dashboard.rb
      # Edit your local copy; future `bin/seams admin` runs leave it alone.

    Audit log:
      Every successful create/update/destroy writes a Core::AuditLog
      row keyed on Auth::Current.identity. No-ops cleanly if the
      core engine isn't installed.

    Run the engine specs:
      bin/rails seams:test[admin]

    See engines/admin/README.md for the full configuration
    reference and the four config knobs (authenticator,
    tenancy_scope, theme_css_path, before_admin_action).

  TXT
end

#rewrite_dummy_routes_for_namespaced_engineObject

DummyAppWriter renders ‘mount Admin::Engine, at: “/admin”` from the `engine_module: “Admin”` argument — but our engine lives at `Seams::Admin::Engine` (two-namespace, matching the gem layout). We can’t pass ‘engine_module: “Seams::Admin”` because DummyAppWriter also uses that value to `require “<downcase>”` the engine’s lib entry point, and ‘require “seams::admin”` isn’t a thing. Cheapest fix: rewrite the dummy’s routes.rb after DummyAppWriter writes it.



235
236
237
238
239
240
241
# File 'lib/generators/seams/admin/admin_generator.rb', line 235

def rewrite_dummy_routes_for_namespaced_engine
  path = File.join(destination_root, "engines", ENGINE_NAME, "spec/dummy/config/routes.rb")
  return unless File.exist?(path)

  contents = File.read(path)
  File.write(path, contents.sub("mount Admin::Engine", "mount Seams::Admin::Engine"))
end

#wire_into_hostObject



306
307
308
309
310
311
312
313
314
# File 'lib/generators/seams/admin/admin_generator.rb', line 306

def wire_into_host
  # Administrate is the dashboard framework; Pundit is the
  # authorisation layer Phase 3 will wire policies through.
  # Both go into the host's main bundle (admin runs in-app, not
  # a separate process).
  host_inject_gem("administrate", "~> 1.0")
  host_inject_gem("pundit",       "~> 2.4")
  host_inject_mount(engine_class: "Seams::Admin::Engine", at: "/admin")
end