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-next 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_gemfile ⇒ Object



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

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_gemfile ⇒ Object

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).



295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
# File 'lib/generators/seams/admin/admin_generator.rb', line 295

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_controller ⇒ Object



137
138
139
140
141
142
143
# File 'lib/generators/seams/admin/admin_generator.rb', line 137

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_concern ⇒ Object



150
151
152
153
# File 'lib/generators/seams/admin/admin_generator.rb', line 150

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

#create_base_engine ⇒ Object

The base engine would mount Admin::Engine and write an Admin.configure initializer, but this engine's constants live under Seams::Admin. wire_into_host mounts the real class.



93
94
95
# File 'lib/generators/seams/admin/admin_generator.rb', line 93

def create_base_engine
  EngineGenerator.start([ENGINE_NAME, "--skip-host-wiring"], destination_root: destination_root)
end

#create_configuration ⇒ Object



145
146
147
148
# File 'lib/generators/seams/admin/admin_generator.rb', line 145

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

#create_context ⇒ Object

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.



159
160
161
162
# File 'lib/generators/seams/admin/admin_generator.rb', line 159

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

#create_dashboards ⇒ Object

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.



219
220
221
222
223
224
225
226
227
228
229
230
231
# File 'lib/generators/seams/admin/admin_generator.rb', line 219

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_app ⇒ Object



233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
# File 'lib/generators/seams/admin/admin_generator.rb', line 233

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
  write_dummy_ability_registrations
  amend_dummy_application_rb
  rewrite_dummy_routes_for_namespaced_engine
end

#create_factories ⇒ Object



313
314
315
316
# File 'lib/generators/seams/admin/admin_generator.rb', line 313

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

#create_fields ⇒ Object

Association fields that name their dashboard explicitly; see templates/app/fields/admin/fields/dashboard_option.rb.tt.



206
207
208
209
210
211
212
213
# File 'lib/generators/seams/admin/admin_generator.rb', line 206

def create_fields
  %w[dashboard_option belongs_to has_many].each do |field|
    template_unless_ejected(
      "app/fields/admin/fields/#{field}.rb.tt",
      engine_path("app/fields/admin/fields/#{field}.rb")
    )
  end
end

#create_policies ⇒ Object



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

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_specs ⇒ Object



324
325
326
327
# File 'lib/generators/seams/admin/admin_generator.rb', line 324

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

#create_unit_specs ⇒ Object



318
319
320
321
322
# File 'lib/generators/seams/admin/admin_generator.rb', line 318

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

#create_view_overrides ⇒ Object

Administrate partial overrides: navigation built from this engine's routes, and an index header whose New link does not depend on the dashboard class name. See each partial's header.



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

def create_view_overrides
  %w[_navigation _index_header].each do |partial|
    template_unless_ejected(
      "app/views/seams/admin/application/#{partial}.html.erb.tt",
      engine_path("app/views/seams/admin/application/#{partial}.html.erb")
    )
  end
end

#overwrite_engine_entry_point ⇒ Object



97
98
99
100
101
# File 'lib/generators/seams/admin/admin_generator.rb', line 97

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_readme ⇒ Object



329
330
331
# File 'lib/generators/seams/admin/admin_generator.rb', line 329

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

#overwrite_routes ⇒ Object



103
104
105
# File 'lib/generators/seams/admin/admin_generator.rb', line 103

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

#remove_single_namespace_leftovers ⇒ Object

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/.



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

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_summary ⇒ Object



343
344
345
# File 'lib/generators/seams/admin/admin_generator.rb', line 343

def report_summary
  say report_summary_text, :green
end

#report_summary_text ⇒ Object



347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
# File 'lib/generators/seams/admin/admin_generator.rb', line 347

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: "[email protected]").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_engine ⇒ Object

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.



262
263
264
265
266
267
268
# File 'lib/generators/seams/admin/admin_generator.rb', line 262

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_host ⇒ Object



333
334
335
336
337
338
339
340
341
# File 'lib/generators/seams/admin/admin_generator.rb', line 333

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